-
Notifications
You must be signed in to change notification settings - Fork 5.5k
feat(terminal): add Cmder as a Windows terminal shell #22462
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
b1nhm1nh
wants to merge
7
commits into
stablyai:main
Choose a base branch
from
b1nhm1nh:feat/cmder-shell
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
c6c1751
feat(terminal): add Cmder as a Windows terminal shell
b1nhm1nh bb29182
fix(terminal): harden Cmder launch and add a Cmder folder setting
b1nhm1nh ceac85f
refactor(settings): hide the Cmder folder input
b1nhm1nh 1e6f7d0
fix(terminal): address Cmder review feedback
b1nhm1nh 9c07334
fix(terminal): resolve Cmder from spawn env on the in-process fallback
b1nhm1nh 0b91e2e
fix(relay): keep a Cmder pane's root across revive
b1nhm1nh cb54aa0
test(terminal): give the Pi owner-env launch plan fixture a Cmder root
b1nhm1nh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import * as pty from 'node-pty' | ||
| import { describe, expect, it } from 'vitest' | ||
| import { applyCmderSpawnEnvironment, resolveCmderRoot } from './cmder' | ||
| import { resolveWindowsShellLaunchArgs } from './providers/windows-shell-args' | ||
|
|
||
| // Real ConPTY launch against a locally installed Cmder; skipped when none is found. | ||
| describe.runIf(process.platform === 'win32' && resolveCmderRoot() !== null)('Cmder launch', () => { | ||
| it('runs init.bat and keeps the requested cwd', async () => { | ||
| const root = resolveCmderRoot()! | ||
| const cwd = process.cwd() | ||
| const launch = resolveWindowsShellLaunchArgs( | ||
| 'cmd.exe', | ||
| cwd, | ||
| cwd, | ||
| undefined, | ||
| // Why `call`: /K expands %VAR% before init.bat runs; `call` re-expands afterwards. | ||
| 'call echo MARK[%^CMDER_ROOT%][%^CMDER_CONFIGURED%][%^CD%]', | ||
| undefined, | ||
| true | ||
| ) | ||
| const env: Record<string, string> = {} | ||
| for (const [key, value] of Object.entries(process.env)) { | ||
| if (value !== undefined && key !== 'CMDER_ROOT') { | ||
| env[key] = value | ||
| } | ||
| } | ||
| applyCmderSpawnEnvironment(env, root) | ||
| const proc = pty.spawn('cmd.exe', launch.shellArgs, { | ||
| cwd: launch.effectiveCwd, | ||
| env, | ||
| cols: 200, | ||
| rows: 40 | ||
| }) | ||
| let out = '' | ||
| proc.onData((data) => { | ||
| out += data | ||
| }) | ||
| const exited = new Promise<void>((resolve) => proc.onExit(() => resolve())) | ||
| const deadline = Date.now() + 30_000 | ||
| while (!out.includes('MARK[C:') && !out.includes(`MARK[${root}`) && Date.now() < deadline) { | ||
| await new Promise((resolve) => setTimeout(resolve, 200)) | ||
| } | ||
| proc.write('exit\r') | ||
| await exited | ||
| expect(out).toContain(`MARK[${root}][1][${cwd}]`) | ||
| }, 60_000) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { | ||
| applyCmderSpawnEnvironment, | ||
| applyConfiguredCmderRootEnv, | ||
| setConfiguredCmderRoot, | ||
| getCmderRootCandidates, | ||
| resolveCmderRoot, | ||
| resolveWindowsCmderShellRoot | ||
| } from './cmder' | ||
|
|
||
| describe('Cmder discovery', () => { | ||
| it('prefers CMDER_ROOT and strips quotes and trailing separators', () => { | ||
| const candidates = getCmderRootCandidates({ | ||
| CMDER_ROOT: '"C:\\programs\\cmder\\"', | ||
| USERPROFILE: 'C:\\Users\\alice' | ||
| }) | ||
| expect(candidates[0]).toBe('C:\\programs\\cmder') | ||
| expect(candidates).toContain('C:\\Users\\alice\\scoop\\apps\\cmder\\current') | ||
| }) | ||
|
|
||
| it('resolves the first root whose vendor init.bat exists', () => { | ||
| expect( | ||
| resolveCmderRoot({ | ||
| platform: 'win32', | ||
| env: { CMDER_ROOT: 'D:\\missing', USERPROFILE: 'C:\\Users\\alice' }, | ||
| exists: (path) => path === 'C:\\Users\\alice\\cmder\\vendor\\init.bat' | ||
| }) | ||
| ).toBe('C:\\Users\\alice\\cmder') | ||
| }) | ||
|
|
||
| it('never resolves off Windows', () => { | ||
| expect( | ||
| resolveCmderRoot({ platform: 'linux', env: { CMDER_ROOT: 'C:\\cmder' }, exists: () => true }) | ||
| ).toBeNull() | ||
| }) | ||
|
|
||
| it('only maps the cmder sentinel', () => { | ||
| const options = { | ||
| platform: 'win32' as const, | ||
| env: { CMDER_ROOT: 'C:\\cmder' }, | ||
| exists: () => true | ||
| } | ||
| expect(resolveWindowsCmderShellRoot('Cmder', options)).toBe('C:\\cmder') | ||
| expect(resolveWindowsCmderShellRoot('cmd.exe', options)).toBeNull() | ||
| }) | ||
|
|
||
| it('sets CMDER_ROOT and the init path/quote pair, dropping inherited init state', () => { | ||
| const env: Record<string, string> = { | ||
| CMDER_CONFIGURED: '2', | ||
| CMDER_INIT_START: '1:00', | ||
| CMDER_INIT_END: '1:01' | ||
| } | ||
| applyCmderSpawnEnvironment(env, 'C:\\Program Files\\cmder') | ||
| expect(env).toEqual({ | ||
| CMDER_ROOT: 'C:\\Program Files\\cmder', | ||
| ORCA_CMDER_INIT: 'C:\\Program Files\\cmder\\vendor\\init.bat', | ||
| ORCA_CMDER_INIT_QUOTE: '"' | ||
| }) | ||
| }) | ||
|
|
||
| it('ranks the spawn-carried setting over CMDER_ROOT, and the main-process setting next', () => { | ||
| expect( | ||
| getCmderRootCandidates({ ORCA_CMDER_ROOT: 'D:\\cmder', CMDER_ROOT: 'C:\\cmder' }).slice(0, 2) | ||
| ).toEqual(['D:\\cmder', 'C:\\cmder']) | ||
| setConfiguredCmderRoot(' E:\\apps\\cmder ') | ||
| try { | ||
| expect(getCmderRootCandidates({ CMDER_ROOT: 'C:\\cmder' })[0]).toBe('E:\\apps\\cmder') | ||
| } finally { | ||
| setConfiguredCmderRoot(null) | ||
| } | ||
| }) | ||
|
|
||
| it('carries only a non-empty setting into spawn env', () => { | ||
| const options: { env?: Record<string, string> } = { env: { PATH: 'x' } } | ||
| applyConfiguredCmderRootEnv(options, ' ') | ||
| expect(options.env).toEqual({ PATH: 'x' }) | ||
| applyConfiguredCmderRootEnv(options, 'D:\\cmder') | ||
| expect(options.env).toEqual({ PATH: 'x', ORCA_CMDER_ROOT: 'D:\\cmder' }) | ||
| applyConfiguredCmderRootEnv(options, '') | ||
| expect(options.env).toEqual({ PATH: 'x' }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import { existsSync } from 'node:fs' | ||
| import { win32 as pathWin32 } from 'node:path' | ||
| import { WINDOWS_CMDER_SHELL } from '../shared/windows-terminal-shell' | ||
|
|
||
| export const ORCA_CMDER_INIT_ENV = 'ORCA_CMDER_INIT' | ||
| export const ORCA_CMDER_INIT_QUOTE_ENV = 'ORCA_CMDER_INIT_QUOTE' | ||
| export const ORCA_CMDER_ROOT_ENV = 'ORCA_CMDER_ROOT' | ||
|
|
||
| let configuredCmderRoot: string | null = null | ||
|
|
||
| /** Carries the Cmder setting into the spawn env so the out-of-process daemon resolves the same root. */ | ||
| export function applyConfiguredCmderRootEnv( | ||
| spawnOptions: { env?: Record<string, string> }, | ||
| configuredRoot: string | undefined | ||
| ): void { | ||
| const root = configuredRoot?.trim() | ||
| // Why drop first: a carried key from an earlier setting would outrank CMDER_ROOT after the setting is cleared. | ||
| const { [ORCA_CMDER_ROOT_ENV]: _stale, ...env } = spawnOptions.env ?? {} | ||
| if (root) { | ||
| spawnOptions.env = { ...env, [ORCA_CMDER_ROOT_ENV]: root } | ||
| } else if (spawnOptions.env) { | ||
| spawnOptions.env = env | ||
| } | ||
| } | ||
|
|
||
| /** Settings-provided Cmder folder; it outranks CMDER_ROOT and guessed install dirs. */ | ||
| export function setConfiguredCmderRoot(root: string | null | undefined): void { | ||
| configuredCmderRoot = root?.trim() || null | ||
| } | ||
|
|
||
| type CmderRootOptions = { | ||
| env?: NodeJS.ProcessEnv | ||
| exists?: (path: string) => boolean | ||
| platform?: NodeJS.Platform | ||
| } | ||
|
|
||
| function readEnv(env: NodeJS.ProcessEnv, names: string[]): string | undefined { | ||
| for (const name of names) { | ||
| const value = env[name]?.trim() | ||
| if (value) { | ||
| return value | ||
| } | ||
| } | ||
| return undefined | ||
| } | ||
|
|
||
| function stripQuotes(value: string): string { | ||
| return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value | ||
| } | ||
|
|
||
| export function getCmderInitScriptPath(root: string): string { | ||
| return pathWin32.join(root, 'vendor', 'init.bat') | ||
| } | ||
|
|
||
| export function getCmderRootCandidates(env: NodeJS.ProcessEnv = process.env): string[] { | ||
| const candidates: string[] = [] | ||
| const seen = new Set<string>() | ||
| const push = (candidate: string | undefined): void => { | ||
| if (!candidate) { | ||
| return | ||
| } | ||
| const normalized = pathWin32.normalize(stripQuotes(candidate)).replace(/\\+$/, '') | ||
| const key = normalized.toLowerCase() | ||
| if (!seen.has(key)) { | ||
| seen.add(key) | ||
| candidates.push(normalized) | ||
| } | ||
| } | ||
|
|
||
| // Why: spawn paths inject the setting as ORCA_CMDER_ROOT so the separate daemon process sees it too. | ||
| push(readEnv(env, [ORCA_CMDER_ROOT_ENV]) ?? configuredCmderRoot ?? undefined) | ||
| // Why: Cmder's installer and docs both publish CMDER_ROOT; it wins over guessed locations. | ||
| push(readEnv(env, ['CMDER_ROOT', 'cmder_root'])) | ||
| const userProfile = readEnv(env, ['USERPROFILE', 'UserProfile']) | ||
| const systemDrive = readEnv(env, ['SystemDrive', 'SYSTEMDRIVE']) ?? 'C:' | ||
| const programRoots = [ | ||
| readEnv(env, ['ProgramFiles', 'PROGRAMFILES']), | ||
| readEnv(env, ['ProgramFiles(x86)', 'PROGRAMFILES(X86)']), | ||
| readEnv(env, ['LOCALAPPDATA', 'LocalAppData']) | ||
| ] | ||
| for (const root of programRoots) { | ||
| if (root) { | ||
| push(pathWin32.join(root, 'cmder')) | ||
| } | ||
| } | ||
| if (userProfile) { | ||
| push(pathWin32.join(userProfile, 'cmder')) | ||
| push(pathWin32.join(userProfile, 'scoop', 'apps', 'cmder', 'current')) | ||
| push(pathWin32.join(userProfile, 'scoop', 'apps', 'cmder-full', 'current')) | ||
| } | ||
| push(pathWin32.join(`${systemDrive}\\`, 'tools', 'cmder')) | ||
| push(pathWin32.join(`${systemDrive}\\`, 'cmder')) | ||
| return candidates | ||
| } | ||
|
|
||
| export function resolveCmderRoot(options: CmderRootOptions = {}): string | null { | ||
| const platform = options.platform ?? process.platform | ||
| if (platform !== 'win32') { | ||
| return null | ||
| } | ||
| const exists = options.exists ?? existsSync | ||
| for (const candidate of getCmderRootCandidates(options.env ?? process.env)) { | ||
| if (exists(getCmderInitScriptPath(candidate))) { | ||
| return candidate | ||
| } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| export function isCmderAvailable(): boolean { | ||
| return resolveCmderRoot() !== null | ||
| } | ||
|
|
||
| /** Orca launched from a Cmder console inherits CMDER_CONFIGURED, which makes init.bat skip setup. */ | ||
| export function stripInheritedCmderState(env: Record<string, string>): void { | ||
| delete env[ORCA_CMDER_ROOT_ENV] | ||
| delete env.CMDER_CONFIGURED | ||
| delete env.CMDER_INIT_START | ||
| delete env.CMDER_INIT_END | ||
| } | ||
|
|
||
| /** Env Cmder's init.bat expects, plus the path/quote pair the cmd `/K` chain expands. */ | ||
| export function applyCmderSpawnEnvironment(env: Record<string, string>, cmderRoot: string): void { | ||
| stripInheritedCmderState(env) | ||
| env.CMDER_ROOT = cmderRoot | ||
| env[ORCA_CMDER_INIT_ENV] = getCmderInitScriptPath(cmderRoot) | ||
| env[ORCA_CMDER_INIT_QUOTE_ENV] = '"' | ||
| } | ||
|
|
||
| /** Cmder root for a requested shell, or null when the request is not Cmder (or Cmder is missing). */ | ||
| export function resolveWindowsCmderShellRoot( | ||
| shell: string | undefined, | ||
| options: CmderRootOptions = {} | ||
| ): string | null { | ||
| return shell?.trim().toLowerCase() === WINDOWS_CMDER_SHELL ? resolveCmderRoot(options) : null | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.