mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
fix(windows): find PowerShell 7 wherever it is installed and say so when it is missing
Search every place PowerShell 7 can be installed (Program Files, per-user, the terminal PATH, App Paths, the Store package) off the main thread, cache the result per shell setting, and retry after a launch failure or a timed-out search. When the shell setting asks for PowerShell 7 and none is found, the fallback shell prints a one-line notice. Main translates the notice in the UI language and passes it with the spawn options, because the terminal daemon has no UI language; English is used when a client does not send one. The cmd notice is caret-escaped so translated text is always printed literally.
This commit is contained in:
@@ -443,7 +443,6 @@
|
||||
1 src/main/providers/local-pty/local-pty-shell-ready-wrapper-root.ts
|
||||
1 src/main/providers/shell-launch/macos/login-session-pty-probe.ts
|
||||
3 src/main/providers/shell-launch/macos/tcc-login-shell.ts
|
||||
3 src/main/providers/shell-launch/windows/powershell-executable.ts
|
||||
2 src/main/providers/working-directory/validation.ts
|
||||
4 src/main/pty/history/fish-history-session.ts
|
||||
4 src/main/pty/history/terminal-history-deletion.ts
|
||||
|
||||
@@ -5,6 +5,7 @@ import { startDaemon, type DaemonHandle } from './server/daemon-main'
|
||||
import { createPtySubprocess } from './pty-subprocess/pty-subprocess'
|
||||
import { warmWindowsConptyOnce } from './pty-subprocess/windows-conpty-warmup'
|
||||
import { warmPwshAvailabilityCache } from '../pty/shell-startup/pwsh'
|
||||
import { warmWindowsPwshSearch } from '../providers/shell-launch/windows/powershell-search'
|
||||
import { createDaemonFileLog, createNoopDaemonFileLog } from './observability/daemon-file-log'
|
||||
import { PROTOCOL_VERSION } from './protocol/daemon-protocol-version'
|
||||
import { detectOwnCgroupScopeUnit, ownFreshDaemonScope } from './process-scopes/daemon-cgroup-scope'
|
||||
@@ -60,6 +61,7 @@ async function main(): Promise<void> {
|
||||
const processCeiling = initializeTerminalScopes(daemonScope, log)
|
||||
startDaemonScopeDeathWatch({ daemonScope, log })
|
||||
void warmPwshAvailabilityCache()
|
||||
warmWindowsPwshSearch()
|
||||
|
||||
// Detached stderr is unavailable; retain TCC preflight failures in diagnostic logs.
|
||||
const runMacosLoginPreflight = async (): Promise<void> => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import type * as HistorySeedProtocol from '../history/seed-transfer/terminal-his
|
||||
import type * as PtyOwnerQueryProtocol from './daemon-pty-owner-query-protocol'
|
||||
import type { TerminalSnapshot } from './terminal-snapshot'
|
||||
import type { SetLaunchNoteRequest } from './daemon-launch-note-protocol'
|
||||
import type { WindowsPwshNotFoundNotices } from '../../providers/shell-launch/windows/pwsh-not-found-notice'
|
||||
|
||||
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
|
||||
|
||||
@@ -53,6 +54,7 @@ export type CreateOrAttachRequest = {
|
||||
terminalWindowsWslDistro?: string | null
|
||||
/** Preserve the selected PowerShell implementation so daemon and local spawns resolve the same executable. */
|
||||
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
|
||||
terminalWindowsPwshNotFoundNotices?: WindowsPwshNotFoundNotices
|
||||
shellReadySupported?: boolean
|
||||
shellReadyTimeoutMs?: number
|
||||
/** Server-side fence that prevents a client timeout from publishing an orphan PTY. */
|
||||
|
||||
@@ -133,6 +133,9 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState {
|
||||
terminalWindowsPowerShellImplementation: context.attachOnly
|
||||
? undefined
|
||||
: opts.terminalWindowsPowerShellImplementation,
|
||||
terminalWindowsPwshNotFoundNotices: context.attachOnly
|
||||
? undefined
|
||||
: opts.terminalWindowsPwshNotFoundNotices,
|
||||
shellReadySupported: context.attachOnly ? false : context.shellReadySupported,
|
||||
...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined
|
||||
? { shellReadyTimeoutMs: context.shellReadyTimeoutMs }
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
wrapShellSpawnForMacosTccAttribution
|
||||
} from '../../providers/shell-launch/macos/tcc-login-shell'
|
||||
import type { WindowsShellSpawnAttempt } from '../../providers/shell-launch/windows/shell-fallback-chain'
|
||||
import { forgetWindowsPowerShellAfterLaunchFailure } from '../../providers/shell-launch/windows/powershell-search'
|
||||
import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job'
|
||||
import { wrapPtySpawnInTerminalScope } from '../process-scopes/terminal-scopes-runtime'
|
||||
import { spawnConptyWithInputReader, type ConptyStartupEvents } from './conpty-startup-input-reader'
|
||||
@@ -149,6 +150,7 @@ export async function spawnNativeDaemonPty(args: {
|
||||
if (process.platform !== 'win32') {
|
||||
throw primaryErr
|
||||
}
|
||||
forgetWindowsPowerShellAfterLaunchFailure(args.shellPath)
|
||||
for (const attempt of args.windowsFallbackAttempts.slice(1)) {
|
||||
try {
|
||||
const spawned = await spawnAt(
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { createDaemonPtySubprocessHandle } from './subprocess-handle'
|
||||
import type { StartupCommandDelivery } from '../../../shared/agent-launch/startup/codex-startup-delivery'
|
||||
import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent'
|
||||
import type { WindowsPwshNotFoundNotices } from '../../providers/shell-launch/windows/pwsh-not-found-notice'
|
||||
|
||||
const PTY_SPAWN_HEALTH_RETRY_ATTEMPTS = 2
|
||||
|
||||
@@ -33,6 +34,7 @@ export type PtySubprocessOptions = {
|
||||
terminalShellArgs?: string[]
|
||||
terminalWindowsWslDistro?: string | null
|
||||
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
|
||||
terminalWindowsPwshNotFoundNotices?: WindowsPwshNotFoundNotices
|
||||
onCreationDisposition?: (disposition: PtyCreationDisposition) => void
|
||||
assertCreateAllowed?: () => void
|
||||
isCanceled?: () => boolean
|
||||
@@ -69,7 +71,7 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise<S
|
||||
try {
|
||||
const size = normalizePtySize(opts.cols, opts.rows)
|
||||
const env = createDaemonPtyEnvironment(opts)
|
||||
const launch = createPtyShellLaunchPlan(opts, env)
|
||||
const launch = await createPtyShellLaunchPlan(opts, env)
|
||||
|
||||
await preflightPtySpawn({
|
||||
validationCwd: launch.validationCwd,
|
||||
|
||||
@@ -59,12 +59,13 @@ export type PtyShellLaunchPlan = {
|
||||
startupAgentRecognition: RecognizedAgentProcess | null
|
||||
}
|
||||
|
||||
export function createPtyShellLaunchPlan(
|
||||
export async function createPtyShellLaunchPlan(
|
||||
opts: PtySubprocessOptions,
|
||||
env: Record<string, string>
|
||||
): PtyShellLaunchPlan {
|
||||
): Promise<PtyShellLaunchPlan> {
|
||||
const resolvedWslContext = resolveWslSessionContext(opts)
|
||||
let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env)
|
||||
const requestedShell = shellPath
|
||||
let shellArgs: string[]
|
||||
let startupCommandDeliveredInShellArgs = false
|
||||
let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = []
|
||||
@@ -111,12 +112,17 @@ export function createPtyShellLaunchPlan(
|
||||
) {
|
||||
env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
|
||||
}
|
||||
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
|
||||
windowsFallbackAttempts = await buildWindowsPowerShellSpawnAttempts({
|
||||
shellPath,
|
||||
cwd: spawnCwd,
|
||||
defaultCwd: resolveSafePtyDefaultCwd(),
|
||||
wslContext: resolvedWslContext,
|
||||
startupCommand: opts.command
|
||||
startupCommand: opts.command,
|
||||
// The daemon outlives app restarts; the terminal env carries main's current PATH.
|
||||
env,
|
||||
requestedShell,
|
||||
powerShellImplementation: opts.terminalWindowsPowerShellImplementation,
|
||||
pwshNotFoundNotices: opts.terminalWindowsPwshNotFoundNotices
|
||||
})
|
||||
const primaryAttempt = windowsFallbackAttempts[0]
|
||||
if (primaryAttempt) {
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ describeOnWindows("Git Bash launcher shell proof with Orca's real launch", () =>
|
||||
env: extraEnv
|
||||
}
|
||||
const env = createDaemonPtyEnvironment(opts)
|
||||
const plan = createPtyShellLaunchPlan(opts, env)
|
||||
const plan = await createPtyShellLaunchPlan(opts, env)
|
||||
expect(isGitForWindowsBashLauncherPath(plan.shellPath)).toBe(true)
|
||||
expect(plan.shellArgs.join(' ')).toContain('exec "$BASH"')
|
||||
// Launcher, exec stub, interactive bash: the shape that a size-1 or size-2 rule never matches.
|
||||
|
||||
@@ -44,8 +44,6 @@ const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
vi.mock('../../../providers/shell-launch/windows/powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe'
|
||||
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
|
||||
|
||||
@@ -126,6 +126,7 @@ export class DaemonTerminalAdmission {
|
||||
terminalShellArgs: payload.terminalShellArgs,
|
||||
terminalWindowsWslDistro: payload.terminalWindowsWslDistro,
|
||||
terminalWindowsPowerShellImplementation: payload.terminalWindowsPowerShellImplementation,
|
||||
terminalWindowsPwshNotFoundNotices: payload.terminalWindowsPwshNotFoundNotices,
|
||||
shellReadySupported: payload.shellReadySupported,
|
||||
historySeedChunks,
|
||||
startupIngress: parsePtyStartupIngressIntent(payload.startupIngress),
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from '../../../shared/agent-session/agent-session-host-authority'
|
||||
import type { PtyIncarnationId } from '../../../shared/terminal/pty/lifecycle/pty-incarnation'
|
||||
import type { TerminalExitCause } from '../../../shared/terminal/pty/lifecycle/terminal-exit-cause'
|
||||
import type { WindowsPwshNotFoundNotices } from '../../providers/shell-launch/windows/pwsh-not-found-notice'
|
||||
|
||||
export type CreateOrAttachOptions = {
|
||||
sessionId: string
|
||||
@@ -28,6 +29,7 @@ export type CreateOrAttachOptions = {
|
||||
terminalShellArgs?: string[]
|
||||
terminalWindowsWslDistro?: string | null
|
||||
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
|
||||
terminalWindowsPwshNotFoundNotices?: WindowsPwshNotFoundNotices
|
||||
shellReadySupported?: boolean
|
||||
shellReadyTimeoutMs?: number
|
||||
historySeedChunks?: readonly string[]
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SubprocessHandle } from '../sessions/session-subprocess-handle'
|
||||
import type { TakePendingOutputResult } from '../protocol/daemon-rpc-contract'
|
||||
import type { TerminalSnapshot } from '../protocol/terminal-snapshot'
|
||||
import type { TerminalSpawnGate } from '../admission/terminal-spawn-gate'
|
||||
import type { WindowsPwshNotFoundNotices } from '../../providers/shell-launch/windows/pwsh-not-found-notice'
|
||||
|
||||
export type TerminalHostOptions = {
|
||||
spawnSubprocess: (opts: {
|
||||
@@ -21,6 +22,7 @@ export type TerminalHostOptions = {
|
||||
terminalShellArgs?: string[]
|
||||
terminalWindowsWslDistro?: string | null
|
||||
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
|
||||
terminalWindowsPwshNotFoundNotices?: WindowsPwshNotFoundNotices
|
||||
onCreationDisposition?: (disposition: PtyCreationDisposition) => void
|
||||
assertCreateAllowed?: () => void
|
||||
isCanceled?: () => boolean
|
||||
|
||||
@@ -72,6 +72,7 @@ export async function spawnAndPublishSession(
|
||||
terminalShellArgs: opts.terminalShellArgs,
|
||||
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,
|
||||
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation,
|
||||
terminalWindowsPwshNotFoundNotices: opts.terminalWindowsPwshNotFoundNotices,
|
||||
isCanceled: opts.isCanceled,
|
||||
assertCreateAllowed: deps.assertCreateAllowed,
|
||||
...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {})
|
||||
|
||||
@@ -12,6 +12,8 @@ import { PI_PROCESS_OWNER_ENV_KEYS } from '../../pty/shell-startup/pi-process-ow
|
||||
import { planCodexNoDaemonLaunch } from '../../pty/shell-startup/codex-no-daemon-launch-command'
|
||||
import { applyAgentWorkspaceTrustToSpawn } from '../../agent-launch/trust/agent-workspace-trust-spawn'
|
||||
import type { PtySpawnOptions } from '../../providers/pty/provider-contract'
|
||||
import { buildWindowsPwshNotFoundNotices } from '../../providers/shell-launch/windows/pwsh-not-found-notice'
|
||||
import { translateMain } from '../../i18n/main-i18n'
|
||||
import type { CodexAccountSelectionTarget } from '../../../shared/codex-selection-lane'
|
||||
import { getStartupTerminalIngressIntent } from '../terminal-startup-color-query-replies'
|
||||
import { CODEX_HOME_ENV_KEYS } from './host-env/codex-home'
|
||||
@@ -249,6 +251,8 @@ export async function assemblePtySpawnLaunchOptions(input: PtySpawnLaunchOptions
|
||||
spawnOptions.terminalWindowsPowerShellImplementation = input.getSettings
|
||||
? (input.getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
||||
: undefined
|
||||
spawnOptions.terminalWindowsPwshNotFoundNotices =
|
||||
buildWindowsPwshNotFoundNotices(translateMain)
|
||||
}
|
||||
}
|
||||
return { spawnOptions, env, launchCommand }
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
wrapShellSpawnForMacosTccAttribution
|
||||
} from '../shell-launch/macos/tcc-login-shell'
|
||||
import { formatLocalPtyEnvironmentDiag } from '../working-directory/validation'
|
||||
import { forgetWindowsPowerShellAfterLaunchFailure } from '../shell-launch/windows/powershell-search'
|
||||
|
||||
let didEnsureSpawnHelperExecutable = false
|
||||
|
||||
@@ -144,6 +145,9 @@ function spawnWindowsFallbackChain(
|
||||
): ShellSpawnResult | null {
|
||||
const { termName = 'xterm-256color', cols, rows, env, ptySpawn } = params
|
||||
const attempts = params.windowsFallbackAttempts ?? []
|
||||
if (attempts.length > 0) {
|
||||
forgetWindowsPowerShellAfterLaunchFailure(params.shellPath)
|
||||
}
|
||||
// Skip the first entry: it is the primary that already failed above.
|
||||
for (const attempt of attempts.slice(1)) {
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
shouldProbeWindowsPowerShellAvailability,
|
||||
type WindowsPowerShellShellFamily
|
||||
} from '../shell-launch/windows/powershell'
|
||||
import { buildWindowsPowerShellSpawnAttempts } from '../shell-launch/windows/shell-fallback-chain'
|
||||
import {
|
||||
buildWindowsPowerShellSpawnAttempts,
|
||||
type WindowsShellSpawnAttempt
|
||||
} from '../shell-launch/windows/shell-fallback-chain'
|
||||
import { resolveWindowsShellLaunchArgs } from '../shell-launch/windows/shell-args'
|
||||
import { assertSafeAgentStartupCwd } from '../working-directory/default-cwd'
|
||||
import type { PtySpawnOptions } from '../pty/provider-contract'
|
||||
@@ -48,7 +51,7 @@ export type LocalPtyLaunchPlan = {
|
||||
effectiveCwd: string
|
||||
validationCwd: string
|
||||
startupCommandDeliveredInShellArgs: boolean
|
||||
windowsFallbackAttempts: ReturnType<typeof buildWindowsPowerShellSpawnAttempts>
|
||||
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
|
||||
shellReadyLaunch: ReturnType<typeof getShellLaunchConfig> | null
|
||||
getFallbackShellReadyConfig:
|
||||
| ((shell: string) => ReturnType<typeof getShellLaunchConfig>)
|
||||
@@ -62,7 +65,7 @@ export type LocalPtyLaunchPlan = {
|
||||
export class DeferredLocalPtyLaunchPlan {
|
||||
constructor(
|
||||
readonly availability: boolean | Promise<boolean>,
|
||||
readonly finish: (available: boolean) => LocalPtyLaunchPlan
|
||||
readonly finish: (available: boolean) => Promise<LocalPtyLaunchPlan>
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -74,7 +77,7 @@ function finalizeLocalPtyLaunchPlan(
|
||||
effectiveCwd: string
|
||||
validationCwd: string
|
||||
startupCommandDeliveredInShellArgs?: boolean
|
||||
windowsFallbackAttempts?: ReturnType<typeof buildWindowsPowerShellSpawnAttempts>
|
||||
windowsFallbackAttempts?: WindowsShellSpawnAttempt[]
|
||||
}
|
||||
): LocalPtyLaunchPlan {
|
||||
ensureNodePtySpawnHelperExecutable()
|
||||
@@ -133,7 +136,7 @@ function createWindowsLocalPtyLaunchPlan(
|
||||
})
|
||||
const shouldResolvePowerShellFamily =
|
||||
powerShellImplementation !== undefined || pathWin32.basename(shellFamily) === shellFamily
|
||||
const finish = (pwshAvailable: boolean): LocalPtyLaunchPlan => {
|
||||
const finish = async (pwshAvailable: boolean): Promise<LocalPtyLaunchPlan> => {
|
||||
let shellPath: string
|
||||
if (resolvedGitBashPath) {
|
||||
shellPath = resolvedGitBashPath
|
||||
@@ -149,12 +152,17 @@ function createWindowsLocalPtyLaunchPlan(
|
||||
: shellFamily
|
||||
}
|
||||
// Bare pwsh.exe may resolve to a failing Store alias; use absolute executable/fallback attempts.
|
||||
const windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
|
||||
const windowsFallbackAttempts = await buildWindowsPowerShellSpawnAttempts({
|
||||
shellPath,
|
||||
cwd,
|
||||
defaultCwd,
|
||||
wslContext: seed.launchWslContext,
|
||||
startupCommand: args.command
|
||||
startupCommand: args.command,
|
||||
// Main's env plus the spawn's own; PATH here is what the shell will see.
|
||||
env: args.env ? { ...process.env, ...args.env } : process.env,
|
||||
requestedShell: shellFamily,
|
||||
powerShellImplementation,
|
||||
pwshNotFoundNotices: args.terminalWindowsPwshNotFoundNotices
|
||||
})
|
||||
const primaryAttempt = windowsFallbackAttempts[0]
|
||||
if (primaryAttempt) {
|
||||
@@ -183,9 +191,11 @@ function createWindowsLocalPtyLaunchPlan(
|
||||
windowsFallbackAttempts
|
||||
})
|
||||
}
|
||||
return shouldProbePwsh
|
||||
? new DeferredLocalPtyLaunchPlan(getOptions().pwshAvailable?.() ?? false, finish)
|
||||
: finish(false)
|
||||
// Locating PowerShell is async, so every Windows plan defers.
|
||||
return new DeferredLocalPtyLaunchPlan(
|
||||
shouldProbePwsh ? (getOptions().pwshAvailable?.() ?? false) : false,
|
||||
finish
|
||||
)
|
||||
}
|
||||
|
||||
export function createLocalPtyLaunchPlan(
|
||||
|
||||
@@ -86,8 +86,6 @@ const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
vi.mock('../shell-launch/windows/powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe'
|
||||
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
|
||||
|
||||
@@ -86,8 +86,6 @@ const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
vi.mock('../shell-launch/windows/powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe'
|
||||
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function spawnLocalPty(
|
||||
if (plan instanceof DeferredLocalPtyLaunchPlan) {
|
||||
const available = await plan.availability
|
||||
throwIfCanceled()
|
||||
plan = plan.finish(available)
|
||||
plan = await plan.finish(available)
|
||||
}
|
||||
throwIfCanceled()
|
||||
// Daemon-off (the daemon failed to start) a restore spawns here: same E9 hold as the daemon's.
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { PtyProcessInfo } from './process-info'
|
||||
import type { TerminalExitCause } from '../../../shared/terminal/pty/lifecycle/terminal-exit-cause'
|
||||
import type { TerminalOwner } from '../../../shared/terminal/pty/lifecycle/terminal-owner'
|
||||
import type { WriteSettlement } from '../../../shared/terminal/pty/lifecycle/pty-write-settlement'
|
||||
import type { WindowsPwshNotFoundNotices } from '../shell-launch/windows/pwsh-not-found-notice'
|
||||
|
||||
export type PtyProviderBufferSnapshot = {
|
||||
data: string
|
||||
@@ -86,6 +87,8 @@ export type PtySpawnOptions = {
|
||||
terminalWindowsWslDistro?: string | null
|
||||
/** Persisted inbox/pwsh choice keeps local and daemon PowerShell-family launches aligned. */
|
||||
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
|
||||
/** Translated by main so a fallback shell can say PowerShell 7 was not found in the UI language. */
|
||||
terminalWindowsPwshNotFoundNotices?: WindowsPwshNotFoundNotices
|
||||
/** Fresh-spawn-only source authority installed before any PTY output is released. */
|
||||
startupIngress?: PtyStartupIngressIntent
|
||||
/** Why the caller is starting this terminal; presence lets the host defer an automatic restore. */
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveWindowsPowerShellSpawnChain } from './powershell-executable'
|
||||
import { forgetWindowsPowerShellAfterLaunchFailure } from './powershell-search'
|
||||
import { buildWindowsPowerShellSpawnAttempts } from './shell-fallback-chain'
|
||||
|
||||
// A simulated machine: real installs are never touched.
|
||||
type SimulatedAppPaths = { machine?: string; user?: string }
|
||||
|
||||
const machine = vi.hoisted(() => {
|
||||
const appPaths: SimulatedAppPaths = {}
|
||||
return {
|
||||
files: new Map<string, string>(),
|
||||
aliases: new Map<string, string>(),
|
||||
// Paths whose stat never settles, like an unreachable network share.
|
||||
unresponsive: new Set<string>(),
|
||||
appPaths
|
||||
}
|
||||
})
|
||||
|
||||
function key(path: string): string {
|
||||
return pathWin32.normalize(path).toLowerCase()
|
||||
}
|
||||
|
||||
function enoent(path: string): Error {
|
||||
return Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: async (path: string) => {
|
||||
if (machine.unresponsive.has(key(path))) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (!machine.files.has(key(path))) {
|
||||
throw enoent(path)
|
||||
}
|
||||
return { isFile: () => true, size: 4096 }
|
||||
},
|
||||
readlink: async (path: string) => {
|
||||
const target = machine.aliases.get(key(path))
|
||||
if (!target) {
|
||||
throw enoent(path)
|
||||
}
|
||||
return target
|
||||
},
|
||||
readdir: async (dir: string) => {
|
||||
const prefix = `${key(dir)}\\`
|
||||
const names = new Set<string>()
|
||||
for (const original of [...machine.files.values(), ...machine.aliases.keys()]) {
|
||||
const normalized = pathWin32.normalize(original)
|
||||
if (normalized.toLowerCase().startsWith(prefix)) {
|
||||
names.add(normalized.slice(prefix.length).split('\\')[0])
|
||||
}
|
||||
}
|
||||
if (names.size === 0) {
|
||||
throw enoent(dir)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../../windows/windows-native-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
loadWindowsNativeRegistry: () => ({
|
||||
HK: { CU: 1, LM: 2 },
|
||||
getRegistryKey: (root: number) => {
|
||||
const value = root === 2 ? machine.appPaths.machine : machine.appPaths.user
|
||||
return value === undefined ? null : { '': { name: '', type: 2, value } }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const ENV: NodeJS.ProcessEnv = {
|
||||
ProgramW6432: 'C:\\Program Files',
|
||||
ProgramFiles: 'C:\\Program Files',
|
||||
'ProgramFiles(x86)': 'C:\\Program Files (x86)',
|
||||
LOCALAPPDATA: 'C:\\Users\\dev\\AppData\\Local',
|
||||
USERPROFILE: 'C:\\Users\\dev',
|
||||
SystemRoot: 'C:\\Windows',
|
||||
ComSpec: 'C:\\Windows\\System32\\cmd.exe',
|
||||
Path: 'C:\\Windows\\System32'
|
||||
}
|
||||
const WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const CMD = 'C:\\Windows\\System32\\cmd.exe'
|
||||
const WINDOWS_APPS = 'C:\\Users\\dev\\AppData\\Local\\Microsoft\\WindowsApps'
|
||||
const STORE_EXE =
|
||||
'C:\\Program Files\\WindowsApps\\Microsoft.PowerShell_7.6.3.0_x64__8wekyb3d8bbwe\\pwsh.exe'
|
||||
|
||||
function install(...paths: string[]): void {
|
||||
for (const path of paths) {
|
||||
machine.files.set(key(path), path)
|
||||
}
|
||||
}
|
||||
|
||||
function uninstall(path: string): void {
|
||||
machine.files.delete(key(path))
|
||||
}
|
||||
|
||||
function alias(path: string, target: string): void {
|
||||
machine.aliases.set(key(path), target)
|
||||
}
|
||||
|
||||
let searchId = 0
|
||||
// Each test gets its own setting so one test's cached search never answers another's.
|
||||
function freshSetting(): string {
|
||||
searchId += 1
|
||||
return `pwsh.exe|test-${searchId}`
|
||||
}
|
||||
|
||||
async function findPwsh(
|
||||
options: { env?: NodeJS.ProcessEnv; settingKey?: string } = {}
|
||||
): Promise<string> {
|
||||
const chain = await resolveWindowsPowerShellSpawnChain('pwsh.exe', {
|
||||
platform: 'win32',
|
||||
env: options.env ?? ENV,
|
||||
settingKey: options.settingKey ?? freshSetting()
|
||||
})
|
||||
return chain[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
machine.files.clear()
|
||||
machine.aliases.clear()
|
||||
machine.unresponsive.clear()
|
||||
machine.appPaths = {}
|
||||
install(WINDOWS_POWERSHELL, CMD)
|
||||
// This process's own PATH is the stale one; tests put fresh entries on the terminal env.
|
||||
vi.stubEnv('PATH', 'C:\\Windows\\System32')
|
||||
vi.stubEnv('Path', 'C:\\Windows\\System32')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('PowerShell 7 discovery sources', () => {
|
||||
it('picks the highest Program Files version across 64-bit and 32-bit roots', async () => {
|
||||
install(
|
||||
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
'C:\\Program Files (x86)\\PowerShell\\6\\pwsh.exe',
|
||||
'C:\\Program Files (x86)\\PowerShell\\8\\pwsh.exe'
|
||||
)
|
||||
expect(await findPwsh()).toBe('C:\\Program Files (x86)\\PowerShell\\8\\pwsh.exe')
|
||||
})
|
||||
|
||||
it('finds a per-user install', async () => {
|
||||
install('C:\\Users\\dev\\AppData\\Local\\Microsoft\\PowerShell\\7\\pwsh.exe')
|
||||
expect(await findPwsh()).toBe(
|
||||
'C:\\Users\\dev\\AppData\\Local\\Microsoft\\PowerShell\\7\\pwsh.exe'
|
||||
)
|
||||
})
|
||||
|
||||
it('finds pwsh on the terminal PATH when this process PATH is stale', async () => {
|
||||
install('D:\\Tools\\pwsh\\pwsh.exe')
|
||||
const env = { ...ENV, Path: 'relative-tools;"D:\\Tools\\pwsh";C:\\Windows\\System32' }
|
||||
expect(await findPwsh({ env })).toBe('D:\\Tools\\pwsh\\pwsh.exe')
|
||||
})
|
||||
|
||||
it('reads both PATH spellings when a merged env carries PATH and Path', async () => {
|
||||
install('D:\\Second\\pwsh.exe')
|
||||
const env = { ...ENV, PATH: 'C:\\Windows\\System32', Path: 'D:\\Second' }
|
||||
expect(await findPwsh({ env })).toBe('D:\\Second\\pwsh.exe')
|
||||
})
|
||||
|
||||
it('stops waiting on an unresponsive PATH entry and uses the install it found', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
try {
|
||||
install('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
|
||||
machine.unresponsive.add(key('\\\\slow-share\\tools\\pwsh.exe'))
|
||||
const found = findPwsh({ env: { ...ENV, Path: '\\\\slow-share\\tools' } })
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(await found).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('searches again once a timed-out only install starts responding', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
try {
|
||||
const settingKey = freshSetting()
|
||||
const env = { ...ENV, Path: '\\\\slow-share\\tools' }
|
||||
install('\\\\slow-share\\tools\\pwsh.exe')
|
||||
machine.unresponsive.add(key('\\\\slow-share\\tools\\pwsh.exe'))
|
||||
const first = findPwsh({ env, settingKey })
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(await first).toBe(WINDOWS_POWERSHELL)
|
||||
|
||||
machine.unresponsive.clear()
|
||||
expect(await findPwsh({ env, settingKey })).toBe('\\\\slow-share\\tools\\pwsh.exe')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the App Paths registration, machine before user', async () => {
|
||||
install('C:\\Program Files\\Custom PS\\pwsh.exe', 'D:\\User PS\\pwsh.exe')
|
||||
machine.appPaths = {
|
||||
machine: '"%ProgramFiles%\\Custom PS\\pwsh.exe"',
|
||||
user: 'D:\\User PS\\pwsh.exe'
|
||||
}
|
||||
expect(await findPwsh()).toBe('C:\\Program Files\\Custom PS\\pwsh.exe')
|
||||
})
|
||||
|
||||
it('resolves the Store install when WindowsApps is not on PATH', async () => {
|
||||
install(STORE_EXE)
|
||||
alias(`${WINDOWS_APPS}\\Microsoft.PowerShell_8wekyb3d8bbwe\\pwsh.exe`, STORE_EXE)
|
||||
expect(await findPwsh()).toBe(STORE_EXE)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'C:\\Users\\dev\\.dotnet\\tools\\pwsh.exe',
|
||||
'C:\\Users\\dev\\scoop\\apps\\pwsh\\current\\pwsh.exe'
|
||||
])('finds the user tool install %s', async (path) => {
|
||||
install(path)
|
||||
expect(await findPwsh()).toBe(path)
|
||||
})
|
||||
|
||||
it('never launches the zero-byte Store alias itself', async () => {
|
||||
install(`${WINDOWS_APPS}\\pwsh.exe`)
|
||||
expect(await findPwsh()).toBe(WINDOWS_POWERSHELL)
|
||||
})
|
||||
|
||||
it.each(['pwsh.exe', `${WINDOWS_APPS}\\pwsh.exe`, 'C:\\Tools\\powershell.exe'])(
|
||||
'rejects unsafe Store alias target %s',
|
||||
async (target) => {
|
||||
install(target)
|
||||
alias(`${WINDOWS_APPS}\\pwsh.exe`, target)
|
||||
expect(await findPwsh()).toBe(WINDOWS_POWERSHELL)
|
||||
}
|
||||
)
|
||||
|
||||
it('uses a preview build only when no stable build exists', async () => {
|
||||
const preview = 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe'
|
||||
install(preview)
|
||||
expect(await findPwsh()).toBe(preview)
|
||||
|
||||
install('C:\\Users\\dev\\.dotnet\\tools\\pwsh.exe')
|
||||
expect(await findPwsh()).toBe('C:\\Users\\dev\\.dotnet\\tools\\pwsh.exe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PowerShell 7 search cache', () => {
|
||||
const programFilesPwsh = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const perUserPwsh = 'C:\\Users\\dev\\AppData\\Local\\Microsoft\\PowerShell\\7\\pwsh.exe'
|
||||
|
||||
it('reuses the found path until the shell setting changes', async () => {
|
||||
const settingKey = freshSetting()
|
||||
install(programFilesPwsh)
|
||||
expect(await findPwsh({ settingKey })).toBe(programFilesPwsh)
|
||||
|
||||
uninstall(programFilesPwsh)
|
||||
install(perUserPwsh)
|
||||
expect(await findPwsh({ settingKey })).toBe(programFilesPwsh)
|
||||
expect(await findPwsh({ settingKey: freshSetting() })).toBe(perUserPwsh)
|
||||
})
|
||||
|
||||
it('searches again after the cached path fails to launch', async () => {
|
||||
const settingKey = freshSetting()
|
||||
install(programFilesPwsh)
|
||||
expect(await findPwsh({ settingKey })).toBe(programFilesPwsh)
|
||||
|
||||
uninstall(programFilesPwsh)
|
||||
install(perUserPwsh)
|
||||
forgetWindowsPowerShellAfterLaunchFailure(programFilesPwsh)
|
||||
expect(await findPwsh({ settingKey })).toBe(perUserPwsh)
|
||||
})
|
||||
|
||||
it('keeps a miss for the same PATH but searches again once the terminal PATH changes', async () => {
|
||||
const settingKey = freshSetting()
|
||||
expect(await findPwsh({ settingKey })).toBe(WINDOWS_POWERSHELL)
|
||||
|
||||
install('D:\\New\\pwsh.exe')
|
||||
expect(await findPwsh({ settingKey })).toBe(WINDOWS_POWERSHELL)
|
||||
const env = { ...ENV, Path: `${ENV.Path};D:\\New` }
|
||||
expect(await findPwsh({ settingKey, env })).toBe('D:\\New\\pwsh.exe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PowerShell 7 search warm-up', () => {
|
||||
it('lets the first terminal find pwsh on its PATH after a warm-up saw a stale PATH', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
vi.resetModules()
|
||||
const search = await import('./powershell-search')
|
||||
install('D:\\Fresh\\pwsh.exe')
|
||||
search.warmWindowsPwshSearch()
|
||||
const env = { ...ENV, Path: 'D:\\Fresh;C:\\Windows\\System32' }
|
||||
expect(await search.findWindowsPwsh({ env, settingKey: 'powershell.exe|pwsh.exe' })).toBe(
|
||||
'D:\\Fresh\\pwsh.exe'
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('PowerShell 7 spawn attempts', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
})
|
||||
|
||||
function attempts(
|
||||
requestedShell: string,
|
||||
implementation: string,
|
||||
pwshNotFoundNotices?: { windowsPowerShell: string; commandPrompt: string }
|
||||
) {
|
||||
return buildWindowsPowerShellSpawnAttempts({
|
||||
pwshNotFoundNotices,
|
||||
shellPath: 'pwsh.exe',
|
||||
cwd: 'C:\\repo',
|
||||
defaultCwd: 'C:\\repo',
|
||||
env: ENV,
|
||||
requestedShell,
|
||||
powerShellImplementation: implementation
|
||||
})
|
||||
}
|
||||
|
||||
// The fallback shell prints the notice itself, so it rides ConPTY like any other output.
|
||||
function startupScript(attempt: { shellPath: string; shellArgs: string[] }): string {
|
||||
return attempt.shellPath === CMD
|
||||
? attempt.shellArgs[1]
|
||||
: Buffer.from(attempt.shellArgs[3], 'base64').toString('utf16le')
|
||||
}
|
||||
|
||||
it('has each fallback shell announce that PowerShell 7 was not found', async () => {
|
||||
const plan = await attempts('powershell.exe', `missing-${freshSetting()}`)
|
||||
expect(plan.map((attempt) => attempt.shellPath)).toEqual([WINDOWS_POWERSHELL, CMD])
|
||||
expect(startupScript(plan[0])).toMatch(/^Write-Host '\[Orca .*\]' -ForegroundColor Yellow\n/)
|
||||
expect(startupScript(plan[1])).toMatch(/ & echo \[Orca .*\]$/)
|
||||
})
|
||||
|
||||
it('prints the notice main translated, kept literal in cmd', async () => {
|
||||
const plan = await attempts('powershell.exe', `translated-${freshSetting()}`, {
|
||||
windowsPowerShell: 'PowerShell 7 fehlt, Windows PowerShell geöffnet.',
|
||||
commandPrompt: 'PowerShell 7 fehlt & Eingabeaufforderung (cmd) geöffnet.'
|
||||
})
|
||||
expect(startupScript(plan[0])).toMatch(
|
||||
/^Write-Host '\[PowerShell 7 fehlt, Windows PowerShell geöffnet\.\]' -ForegroundColor Yellow\n/
|
||||
)
|
||||
expect(startupScript(plan[1])).toMatch(
|
||||
/ & echo \[PowerShell 7 fehlt \^& Eingabeaufforderung \^\(cmd\^\) geöffnet\.\]$/
|
||||
)
|
||||
})
|
||||
|
||||
it('opens PowerShell 7 without a notice when it is found', async () => {
|
||||
install('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
|
||||
const plan = await attempts('powershell.exe', `found-${freshSetting()}`)
|
||||
expect(plan[0].shellPath).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
|
||||
expect(plan.map(startupScript).join('\n')).not.toContain('[Orca')
|
||||
})
|
||||
|
||||
it('prefers a preview build when the shell setting points at one', async () => {
|
||||
const preview = 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe'
|
||||
install('C:\\Program Files\\PowerShell\\7\\pwsh.exe', preview)
|
||||
const plan = await attempts(preview, `preview-${freshSetting()}`)
|
||||
expect(plan[0].shellPath).toBe(preview)
|
||||
})
|
||||
})
|
||||
@@ -1,196 +1,49 @@
|
||||
import { existsSync, readlinkSync, statSync } from 'node:fs'
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import { readWindowsEnv } from './pwsh-discovery'
|
||||
import {
|
||||
findWindowsPowerShell,
|
||||
findWindowsPwsh,
|
||||
type WindowsPowerShellResolveOptions
|
||||
} from './powershell-search'
|
||||
|
||||
export type WindowsPowerShellResolveOptions = {
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Returns true when the candidate path is a real, non-empty executable. */
|
||||
isRealExecutable?: (path: string) => boolean
|
||||
/** Resolves a Store App Execution Alias to its package executable. */
|
||||
resolveAppExecutionAlias?: (path: string) => string | null
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
/** Store alias reparse stubs may launch through execFile PATH lookup but fail ConPTY absolute CreateProcessW (5). */
|
||||
function isWindowsAppExecutionAliasPath(candidate: string): boolean {
|
||||
return /[\\/]Microsoft[\\/]WindowsApps[\\/]/i.test(pathWin32.normalize(candidate))
|
||||
}
|
||||
|
||||
/** Reject zero-byte alias stubs even if future Windows versions move them outside WindowsApps. */
|
||||
function defaultIsRealExecutable(candidate: string): boolean {
|
||||
if (isWindowsAppExecutionAliasPath(candidate)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (!existsSync(candidate)) {
|
||||
return false
|
||||
}
|
||||
const stat = statSync(candidate)
|
||||
return stat.isFile() && stat.size > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function defaultResolveAppExecutionAlias(candidate: string): string | null {
|
||||
try {
|
||||
const target = readlinkSync(candidate)
|
||||
return pathWin32.resolve(pathWin32.dirname(candidate), target)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readEnv(env: NodeJS.ProcessEnv, names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
const value = env[name]
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getPathExecutableCandidates(env: NodeJS.ProcessEnv, executable: string): string[] {
|
||||
const pathValue = readEnv(env, ['PATH', 'Path', 'path'])
|
||||
if (!pathValue) {
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const rawPart of pathValue.split(pathWin32.delimiter)) {
|
||||
const dir = rawPart.trim().replace(/^"|"$/g, '')
|
||||
// Relative PATH entries enable repo-local shell hijacking; accept only absolute directories.
|
||||
if (!pathWin32.isAbsolute(dir)) {
|
||||
continue
|
||||
}
|
||||
pushUniqueCandidate(candidates, seen, pathWin32.join(dir, executable))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function pushUniqueCandidate(
|
||||
candidates: string[],
|
||||
seen: Set<string>,
|
||||
candidate: string | undefined
|
||||
): void {
|
||||
if (!candidate) {
|
||||
return
|
||||
}
|
||||
const normalized = pathWin32.normalize(candidate)
|
||||
const key = normalized.toLowerCase()
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
candidates.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
function getPwshCandidatePaths(env: NodeJS.ProcessEnv): string[] {
|
||||
const candidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const programFilesRoots = [
|
||||
readEnv(env, ['ProgramW6432', 'PROGRAMW6432']),
|
||||
readEnv(env, ['ProgramFiles', 'PROGRAMFILES']),
|
||||
readEnv(env, ['ProgramFiles(x86)', 'PROGRAMFILES(X86)'])
|
||||
]
|
||||
for (const root of programFilesRoots) {
|
||||
if (!root) {
|
||||
continue
|
||||
}
|
||||
// Prefer major 7 while probing adjacent 8/6 install directories for compatibility.
|
||||
for (const major of ['7', '8', '6']) {
|
||||
pushUniqueCandidate(candidates, seen, pathWin32.join(root, 'PowerShell', major, 'pwsh.exe'))
|
||||
}
|
||||
}
|
||||
const localAppData = readEnv(env, ['LOCALAPPDATA', 'LocalAppData'])
|
||||
if (localAppData) {
|
||||
for (const major of ['7', '8', '6']) {
|
||||
pushUniqueCandidate(
|
||||
candidates,
|
||||
seen,
|
||||
pathWin32.join(localAppData, 'Microsoft', 'PowerShell', major, 'pwsh.exe')
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const candidate of getPathExecutableCandidates(env, 'pwsh.exe')) {
|
||||
pushUniqueCandidate(candidates, seen, candidate)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function getWindowsPowerShellPath(env: NodeJS.ProcessEnv): string {
|
||||
const systemRoot = readEnv(env, ['SystemRoot', 'WINDIR', 'windir']) || 'C:\\Windows'
|
||||
return pathWin32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
||||
}
|
||||
export type { WindowsPowerShellResolveOptions }
|
||||
|
||||
export function getWindowsCmdPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const comspec = readEnv(env, ['ComSpec', 'COMSPEC'])
|
||||
const comspec = readWindowsEnv([env], ['ComSpec', 'COMSPEC'])
|
||||
if (comspec) {
|
||||
return comspec
|
||||
}
|
||||
const systemRoot = readEnv(env, ['SystemRoot', 'WINDIR', 'windir']) || 'C:\\Windows'
|
||||
const systemRoot = readWindowsEnv([env], ['SystemRoot', 'WINDIR', 'windir']) || 'C:\\Windows'
|
||||
return pathWin32.join(systemRoot, 'System32', 'cmd.exe')
|
||||
}
|
||||
|
||||
/** Null when neither a real executable nor safe Store package target resolves; callers try the next shell. */
|
||||
export function resolveWindowsPowerShellExecutablePath(
|
||||
family: 'pwsh.exe' | 'powershell.exe',
|
||||
options: WindowsPowerShellResolveOptions = {}
|
||||
): string | null {
|
||||
const platform = options.platform ?? process.platform
|
||||
if (platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
const env = options.env ?? process.env
|
||||
const isRealExecutable = options.isRealExecutable ?? defaultIsRealExecutable
|
||||
const resolveAppExecutionAlias =
|
||||
options.resolveAppExecutionAlias ?? defaultResolveAppExecutionAlias
|
||||
|
||||
if (family === 'powershell.exe') {
|
||||
const windowsPowerShell = getWindowsPowerShellPath(env)
|
||||
return isRealExecutable(windowsPowerShell) ? windowsPowerShell : null
|
||||
}
|
||||
|
||||
for (const candidate of getPwshCandidatePaths(env)) {
|
||||
if (isWindowsAppExecutionAliasPath(candidate)) {
|
||||
const target = resolveAppExecutionAlias(candidate)
|
||||
// ConPTY cannot launch the alias itself; its real package target preserves the requested pwsh.
|
||||
if (
|
||||
target &&
|
||||
pathWin32.isAbsolute(target) &&
|
||||
pathWin32.basename(target).toLowerCase() === 'pwsh.exe' &&
|
||||
!isWindowsAppExecutionAliasPath(target) &&
|
||||
isRealExecutable(target)
|
||||
) {
|
||||
return pathWin32.normalize(target)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isRealExecutable(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Deduplicated requested PowerShell → inbox PowerShell → cmd.exe fallback paths. */
|
||||
export function resolveWindowsPowerShellSpawnChain(
|
||||
export async function resolveWindowsPowerShellSpawnChain(
|
||||
requestedFamily: 'pwsh.exe' | 'powershell.exe',
|
||||
options: WindowsPowerShellResolveOptions = {}
|
||||
): string[] {
|
||||
): Promise<string[]> {
|
||||
const env = options.env ?? process.env
|
||||
const chain: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const add = (candidate: string | null): void => {
|
||||
if (candidate) {
|
||||
pushUniqueCandidate(chain, seen, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
add(resolveWindowsPowerShellExecutablePath(requestedFamily, options))
|
||||
// Inbox PowerShell avoids Store alias launch hazards.
|
||||
add(resolveWindowsPowerShellExecutablePath('powershell.exe', options))
|
||||
const isWindows = (options.platform ?? process.platform) === 'win32'
|
||||
const [pwsh, windowsPowerShell] = isWindows
|
||||
? await Promise.all([
|
||||
requestedFamily === 'pwsh.exe' ? findWindowsPwsh(options) : null,
|
||||
// Inbox PowerShell avoids Store alias launch hazards.
|
||||
findWindowsPowerShell(env)
|
||||
])
|
||||
: [null, null]
|
||||
// cmd.exe remains the last attempt when policy blocks both PowerShell variants.
|
||||
pushUniqueCandidate(chain, seen, getWindowsCmdPath(env))
|
||||
const chain = [pwsh, windowsPowerShell, getWindowsCmdPath(env)].filter(
|
||||
(candidate): candidate is string => Boolean(candidate)
|
||||
)
|
||||
const seen = new Set<string>()
|
||||
return chain
|
||||
.map((candidate) => pathWin32.normalize(candidate))
|
||||
.filter((candidate) => {
|
||||
const key = candidate.toLowerCase()
|
||||
if (seen.has(key)) {
|
||||
return false
|
||||
}
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import {
|
||||
discoverWindowsPwshInstalls,
|
||||
isRealWindowsExecutable,
|
||||
readWindowsEnv,
|
||||
readWindowsPathValue,
|
||||
selectWindowsPwsh,
|
||||
type WindowsPwshInstall
|
||||
} from './pwsh-discovery'
|
||||
|
||||
export type WindowsPowerShellResolveOptions = {
|
||||
/** The environment the terminal will get. */
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** The requested shell setting; a different value searches again. */
|
||||
settingKey?: string
|
||||
preferPreview?: boolean
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
type PwshSearch = {
|
||||
/** Null for a warm-up search, which the first request's setting adopts. */
|
||||
settingKey: string | null
|
||||
searchedPath: string
|
||||
installs: Promise<WindowsPwshInstall[]>
|
||||
settled?: WindowsPwshInstall[]
|
||||
/** False when a probe missed its deadline: an empty result is then not a confirmed miss. */
|
||||
complete?: boolean
|
||||
missLogged?: boolean
|
||||
}
|
||||
|
||||
// Cached per process (main and the terminal daemon each keep one) so tab opens never search the disk.
|
||||
let pwshSearch: PwshSearch | null = null
|
||||
let windowsPowerShellFound: string | null = null
|
||||
|
||||
function readPath(env: NodeJS.ProcessEnv): string {
|
||||
return readWindowsPathValue(env)
|
||||
}
|
||||
|
||||
function startPwshSearch(
|
||||
settingKey: string | null,
|
||||
options: WindowsPowerShellResolveOptions
|
||||
): PwshSearch {
|
||||
const env = options.env ?? process.env
|
||||
const search: PwshSearch = {
|
||||
settingKey,
|
||||
searchedPath: readPath(env),
|
||||
installs: discoverWindowsPwshInstalls(env).then(
|
||||
(discovery) => {
|
||||
search.settled = discovery.installs
|
||||
search.complete = discovery.complete
|
||||
return discovery.installs
|
||||
},
|
||||
(error: unknown) => {
|
||||
// A failed search must not pin the result; the next spawn searches again.
|
||||
if (pwshSearch === search) {
|
||||
pwshSearch = null
|
||||
}
|
||||
console.warn('[pty] PowerShell 7 (pwsh.exe) search failed:', error)
|
||||
return []
|
||||
}
|
||||
)
|
||||
}
|
||||
return search
|
||||
}
|
||||
|
||||
/** Searches once per shell setting; a miss is searched again only when the terminal's PATH differs. */
|
||||
export async function findWindowsPwsh(
|
||||
options: WindowsPowerShellResolveOptions
|
||||
): Promise<string | null> {
|
||||
const settingKey = options.settingKey ?? ''
|
||||
const requestPath = readPath(options.env ?? process.env)
|
||||
if (pwshSearch?.settingKey === null) {
|
||||
pwshSearch.settingKey = settingKey
|
||||
}
|
||||
let search = pwshSearch
|
||||
if (!search || search.settingKey !== settingKey) {
|
||||
search = startPwshSearch(settingKey, options)
|
||||
pwshSearch = search
|
||||
}
|
||||
let installs = await search.installs
|
||||
// A daemon started before an install sees a stale PATH; the terminal's PATH may know better.
|
||||
if (installs.length === 0 && search.searchedPath !== requestPath) {
|
||||
if (pwshSearch === search || pwshSearch?.settingKey !== settingKey) {
|
||||
pwshSearch = startPwshSearch(settingKey, options)
|
||||
}
|
||||
search = pwshSearch
|
||||
installs = await search.installs
|
||||
}
|
||||
if (installs.length === 0 && search.complete === false) {
|
||||
// Only an unanswered probe kept this empty; the next terminal searches again.
|
||||
if (pwshSearch === search) {
|
||||
pwshSearch = null
|
||||
}
|
||||
} else if (installs.length === 0 && !search.missLogged) {
|
||||
search.missLogged = true
|
||||
console.warn(
|
||||
'[pty] PowerShell 7 (pwsh.exe) was not found in Program Files, the per-user install folder, PATH, App Paths, the Store install, or user tool folders'
|
||||
)
|
||||
}
|
||||
return selectWindowsPwsh(installs, options.preferPreview === true)
|
||||
}
|
||||
|
||||
/** Starts the search before the first terminal asks, so that terminal opens no slower. */
|
||||
export function warmWindowsPwshSearch(): void {
|
||||
if (process.platform === 'win32' && !pwshSearch) {
|
||||
pwshSearch = startPwshSearch(null, {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function findWindowsPowerShell(env: NodeJS.ProcessEnv): Promise<string | null> {
|
||||
const systemRoot = readWindowsEnv([env], ['SystemRoot', 'WINDIR', 'windir']) || 'C:\\Windows'
|
||||
const candidate = pathWin32.join(
|
||||
systemRoot,
|
||||
'System32',
|
||||
'WindowsPowerShell',
|
||||
'v1.0',
|
||||
'powershell.exe'
|
||||
)
|
||||
if (windowsPowerShellFound === candidate) {
|
||||
return candidate
|
||||
}
|
||||
// Only a hit is cached: a missing inbox shell is rare and must not stick.
|
||||
if (!(await isRealWindowsExecutable(candidate))) {
|
||||
return null
|
||||
}
|
||||
windowsPowerShellFound = candidate
|
||||
return candidate
|
||||
}
|
||||
|
||||
/** A launch of a cached shell failed: search again on the next spawn. */
|
||||
export function forgetWindowsPowerShellAfterLaunchFailure(shellPath: string): void {
|
||||
const failed = pathWin32.normalize(shellPath).toLowerCase()
|
||||
if (pwshSearch?.settled?.some((install) => install.path.toLowerCase() === failed)) {
|
||||
pwshSearch = null
|
||||
}
|
||||
if (windowsPowerShellFound?.toLowerCase() === failed) {
|
||||
windowsPowerShellFound = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { readdir, readlink, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import { expandWindowsEnvironmentVariables } from '../../../../shared/windows-environment-expansion'
|
||||
import {
|
||||
loadWindowsNativeRegistry,
|
||||
WINDOWS_REG_EXPAND_SZ,
|
||||
WINDOWS_REG_SZ
|
||||
} from '../../../windows/windows-native-registry'
|
||||
|
||||
export type WindowsPwshInstall = { path: string; preview: boolean }
|
||||
|
||||
/** `complete` is false when a probe missed the deadline, so an empty result is not a confirmed miss. */
|
||||
export type WindowsPwshDiscovery = { installs: WindowsPwshInstall[]; complete: boolean }
|
||||
|
||||
type Candidate = { path: string; alias: boolean }
|
||||
|
||||
const APP_PATHS_PWSH_KEY = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pwsh.exe'
|
||||
const VERSION_DIRECTORY = /^(\d+)(-preview)?$/i
|
||||
const STORE_PACKAGE_DIRECTORY = /^Microsoft\.PowerShell(Preview)?_/i
|
||||
// Local probes finish in milliseconds; this bounds only unreachable network PATH entries.
|
||||
const PROBE_DEADLINE_MS = 1_000
|
||||
|
||||
/** Store alias reparse stubs may launch through execFile PATH lookup but fail ConPTY absolute CreateProcessW (5). */
|
||||
export function isWindowsAppExecutionAliasPath(candidate: string): boolean {
|
||||
return /[\\/]Microsoft[\\/]WindowsApps[\\/]/i.test(pathWin32.normalize(candidate))
|
||||
}
|
||||
|
||||
export function isPreviewPwshPath(candidate: string): boolean {
|
||||
return /[\\/]\d+-preview[\\/]|[\\/]Microsoft\.PowerShellPreview_/i.test(candidate)
|
||||
}
|
||||
|
||||
export function readWindowsEnv(
|
||||
envs: readonly NodeJS.ProcessEnv[],
|
||||
names: readonly string[]
|
||||
): string | undefined {
|
||||
for (const env of envs) {
|
||||
for (const name of names) {
|
||||
const value = env[name]
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function isRealWindowsExecutable(candidate: string): Promise<boolean> {
|
||||
// Zero-byte alias stubs are never launchable even if a future Windows moves them.
|
||||
if (isWindowsAppExecutionAliasPath(candidate)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const info = await stat(candidate)
|
||||
return info.isFile() && info.size > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAppExecutionAlias(candidate: string): Promise<string | null> {
|
||||
try {
|
||||
const target = await readlink(candidate)
|
||||
return pathWin32.resolve(pathWin32.dirname(candidate), target)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function listDirectory(directory: string): Promise<string[]> {
|
||||
try {
|
||||
return await readdir(directory)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function readAppPathValues(): string[] {
|
||||
let registry: ReturnType<typeof loadWindowsNativeRegistry>
|
||||
try {
|
||||
registry = loadWindowsNativeRegistry()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const values: string[] = []
|
||||
for (const root of [registry.HK.LM, registry.HK.CU]) {
|
||||
try {
|
||||
const entry = registry.getRegistryKey(root, APP_PATHS_PWSH_KEY)?.['']
|
||||
if (
|
||||
entry &&
|
||||
(entry.type === WINDOWS_REG_SZ || entry.type === WINDOWS_REG_EXPAND_SZ) &&
|
||||
typeof entry.value === 'string'
|
||||
) {
|
||||
values.push(entry.value)
|
||||
}
|
||||
} catch {
|
||||
// An unreadable hive only removes this source.
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function toCandidate(path: string): Candidate {
|
||||
return { path, alias: isWindowsAppExecutionAliasPath(path) }
|
||||
}
|
||||
|
||||
/** `<root>\<version>\pwsh.exe` for every numbered install, highest major first. */
|
||||
async function versionedInstallCandidates(roots: (string | undefined)[]): Promise<Candidate[]> {
|
||||
const uniqueRoots = [...new Set(roots.filter((root): root is string => Boolean(root)))]
|
||||
const listings = await Promise.all(uniqueRoots.map((root) => listDirectory(root)))
|
||||
const installs = listings.flatMap((names, rootIndex) =>
|
||||
names.flatMap((name) => {
|
||||
const match = VERSION_DIRECTORY.exec(name)
|
||||
return match
|
||||
? [
|
||||
{
|
||||
major: Number(match[1]),
|
||||
path: pathWin32.join(uniqueRoots[rootIndex], name, 'pwsh.exe')
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
)
|
||||
// Stable sort keeps 64-bit roots ahead of 32-bit ones for the same major.
|
||||
installs.sort((a, b) => b.major - a.major)
|
||||
return installs.map((install) => toCandidate(install.path))
|
||||
}
|
||||
|
||||
/** Every PATH spelling in key order: a merged env object can hold both `PATH` and `Path`. */
|
||||
export function readWindowsPathValue(env: NodeJS.ProcessEnv): string {
|
||||
return Object.keys(env)
|
||||
.filter((name) => name.toLowerCase() === 'path')
|
||||
.map((name) => env[name])
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(pathWin32.delimiter)
|
||||
}
|
||||
|
||||
function pathDirectoryCandidates(envs: readonly NodeJS.ProcessEnv[]): Candidate[] {
|
||||
const candidates: Candidate[] = []
|
||||
for (const env of envs) {
|
||||
for (const rawPart of readWindowsPathValue(env).split(pathWin32.delimiter)) {
|
||||
const dir = rawPart.trim().replace(/^"|"$/g, '')
|
||||
// Relative PATH entries enable repo-local shell hijacking; accept only absolute directories.
|
||||
if (pathWin32.isAbsolute(dir)) {
|
||||
candidates.push(toCandidate(pathWin32.join(dir, 'pwsh.exe')))
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function appPathCandidates(terminalEnv: NodeJS.ProcessEnv): Candidate[] {
|
||||
const expansionEnv = { ...process.env, ...terminalEnv }
|
||||
return readAppPathValues()
|
||||
.map((value) => expandWindowsEnvironmentVariables(value.trim(), expansionEnv))
|
||||
.map((value) => value.replace(/^"|"$/g, ''))
|
||||
.filter((value) => pathWin32.isAbsolute(value))
|
||||
.map(toCandidate)
|
||||
}
|
||||
|
||||
async function storeCandidates(windowsApps: string | undefined): Promise<Candidate[]> {
|
||||
if (!windowsApps) {
|
||||
return []
|
||||
}
|
||||
const packageDirs = (await listDirectory(windowsApps)).filter((name) =>
|
||||
STORE_PACKAGE_DIRECTORY.test(name)
|
||||
)
|
||||
return [
|
||||
pathWin32.join(windowsApps, 'pwsh.exe'),
|
||||
...packageDirs.flatMap((name) => [
|
||||
pathWin32.join(windowsApps, name, 'pwsh.exe'),
|
||||
pathWin32.join(windowsApps, name, 'pwsh-preview.exe')
|
||||
])
|
||||
].map(toCandidate)
|
||||
}
|
||||
|
||||
async function probeCandidate(candidate: Candidate): Promise<string | null> {
|
||||
if (!candidate.alias) {
|
||||
return (await isRealWindowsExecutable(candidate.path)) ? candidate.path : null
|
||||
}
|
||||
const target = await resolveAppExecutionAlias(candidate.path)
|
||||
// ConPTY cannot launch the alias itself; its real package target preserves the requested pwsh.
|
||||
if (
|
||||
target &&
|
||||
pathWin32.isAbsolute(target) &&
|
||||
pathWin32.basename(target).toLowerCase() === 'pwsh.exe' &&
|
||||
!isWindowsAppExecutionAliasPath(target) &&
|
||||
(await isRealWindowsExecutable(target))
|
||||
) {
|
||||
return pathWin32.normalize(target)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Every launchable pwsh.exe, best first. Reads only; never starts pwsh. */
|
||||
export async function discoverWindowsPwshInstalls(
|
||||
/** The environment the terminal will get; its PATH outranks this process's possibly stale one. */
|
||||
terminalEnv: NodeJS.ProcessEnv
|
||||
): Promise<WindowsPwshDiscovery> {
|
||||
const envs = [terminalEnv, process.env]
|
||||
const programFilesRoots = [
|
||||
['ProgramW6432', 'PROGRAMW6432'],
|
||||
['ProgramFiles', 'PROGRAMFILES'],
|
||||
['ProgramFiles(x86)', 'PROGRAMFILES(X86)']
|
||||
].map((names) => {
|
||||
const root = readWindowsEnv(envs, names)
|
||||
return root ? pathWin32.join(root, 'PowerShell') : undefined
|
||||
})
|
||||
const localAppData = readWindowsEnv(envs, ['LOCALAPPDATA', 'LocalAppData'])
|
||||
const home = readWindowsEnv(envs, ['USERPROFILE', 'UserProfile']) ?? homedir()
|
||||
const [programFiles, perUser, store] = await Promise.all([
|
||||
versionedInstallCandidates(programFilesRoots),
|
||||
versionedInstallCandidates([
|
||||
localAppData ? pathWin32.join(localAppData, 'Microsoft', 'PowerShell') : undefined
|
||||
]),
|
||||
storeCandidates(
|
||||
localAppData ? pathWin32.join(localAppData, 'Microsoft', 'WindowsApps') : undefined
|
||||
)
|
||||
])
|
||||
const ordered = [
|
||||
...programFiles,
|
||||
...perUser,
|
||||
...pathDirectoryCandidates(envs),
|
||||
...appPathCandidates(terminalEnv),
|
||||
...store,
|
||||
toCandidate(pathWin32.join(home, '.dotnet', 'tools', 'pwsh.exe')),
|
||||
toCandidate(pathWin32.join(home, 'scoop', 'apps', 'pwsh', 'current', 'pwsh.exe'))
|
||||
]
|
||||
const seenCandidates = new Set<string>()
|
||||
const candidates = ordered.filter((candidate) => {
|
||||
const key = pathWin32.normalize(candidate.path).toLowerCase()
|
||||
if (seenCandidates.has(key)) {
|
||||
return false
|
||||
}
|
||||
seenCandidates.add(key)
|
||||
return true
|
||||
})
|
||||
// A PATH entry on an unresponsive share can stall stat; stop waiting rather than hold the tab.
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timedOut = Symbol('timed-out')
|
||||
const deadline = new Promise<typeof timedOut>((resolve) => {
|
||||
timer = setTimeout(() => resolve(timedOut), PROBE_DEADLINE_MS)
|
||||
timer.unref?.()
|
||||
})
|
||||
const resolved = await Promise.all(
|
||||
candidates.map((candidate) => Promise.race([probeCandidate(candidate), deadline]))
|
||||
).finally(() => clearTimeout(timer))
|
||||
const installs: WindowsPwshInstall[] = []
|
||||
const seenInstalls = new Set<string>()
|
||||
resolved.forEach((path, index) => {
|
||||
if (path === timedOut) {
|
||||
return
|
||||
}
|
||||
const key = path?.toLowerCase()
|
||||
if (!path || !key || seenInstalls.has(key)) {
|
||||
return
|
||||
}
|
||||
seenInstalls.add(key)
|
||||
installs.push({
|
||||
path,
|
||||
preview: isPreviewPwshPath(path) || isPreviewPwshPath(candidates[index].path)
|
||||
})
|
||||
})
|
||||
return { installs, complete: !resolved.includes(timedOut) }
|
||||
}
|
||||
|
||||
/** Stable unless only a preview exists; the reverse when the setting asks for a preview build. */
|
||||
export function selectWindowsPwsh(
|
||||
installs: readonly WindowsPwshInstall[],
|
||||
preferPreview: boolean
|
||||
): string | null {
|
||||
return (
|
||||
(installs.find((install) => install.preview === preferPreview) ?? installs[0])?.path ?? null
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Per fallback shell; translated in main because the terminal daemon has no UI language. */
|
||||
export type WindowsPwshNotFoundNotices = {
|
||||
windowsPowerShell: string
|
||||
commandPrompt: string
|
||||
}
|
||||
|
||||
export function buildWindowsPwshNotFoundNotices(
|
||||
translate: (key: string, fallback: string) => string = (_key, fallback) => fallback
|
||||
): WindowsPwshNotFoundNotices {
|
||||
return {
|
||||
windowsPowerShell: translate(
|
||||
'terminal.windowsPwshNotFound.windowsPowerShell',
|
||||
'Orca could not find PowerShell 7 (pwsh.exe), so this terminal opened Windows PowerShell instead.'
|
||||
),
|
||||
commandPrompt: translate(
|
||||
'terminal.windowsPwshNotFound.commandPrompt',
|
||||
'Orca could not find PowerShell 7 (pwsh.exe), so this terminal opened Command Prompt instead.'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -81,14 +81,26 @@ function getPowerShellRestoreCwdCommand(cwd: string): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Translated text must stay one literal echo: caret-escape cmd operators and drop quotes and line breaks. */
|
||||
function escapeCmdEchoText(text: string): string {
|
||||
return text
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.replace(/"/g, "'")
|
||||
.replace(/([\^&|<>()%!])/g, '^$1')
|
||||
}
|
||||
|
||||
function getPowerShellEncodedCommand(
|
||||
cwd: string,
|
||||
startupCommand?: string
|
||||
startupCommand?: string,
|
||||
startupNotice?: string
|
||||
): {
|
||||
encodedCommand: string
|
||||
startupCommandDeliveredInShellArgs?: boolean
|
||||
} {
|
||||
const bootstrap = `${getPowerShellOsc133Bootstrap()}${getPowerShellRestoreCwdCommand(cwd)}`
|
||||
const notice = startupNotice
|
||||
? `Write-Host ${quoteStartupArg(startupNotice, 'powershell')} -ForegroundColor Yellow\n`
|
||||
: ''
|
||||
const bootstrap = `${notice}${getPowerShellOsc133Bootstrap()}${getPowerShellRestoreCwdCommand(cwd)}`
|
||||
if (!startupCommand || startupCommand.length > STARTUP_COMMAND_TEXT_MAX_CHARS) {
|
||||
return { encodedCommand: encodePowerShellCommand(bootstrap) }
|
||||
}
|
||||
@@ -126,7 +138,9 @@ export function resolveWindowsShellLaunchArgs(
|
||||
defaultCwd: string,
|
||||
wslContext?: WindowsShellWslContext,
|
||||
startupCommand?: string,
|
||||
codexLaunchPreflightCommand?: string
|
||||
codexLaunchPreflightCommand?: string,
|
||||
/** Printed by the shell itself so ConPTY's screen model stays in sync. */
|
||||
startupNotice?: string
|
||||
): WindowsShellLaunchArgs {
|
||||
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
|
||||
const nativeCwd = msysDrivePathToWindows(cwd)
|
||||
@@ -135,6 +149,7 @@ export function resolveWindowsShellLaunchArgs(
|
||||
const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand)
|
||||
const startupCommands = [
|
||||
CMD_UTF8_SETUP_COMMAND,
|
||||
...(startupNotice ? [`echo ${escapeCmdEchoText(startupNotice)}`] : []),
|
||||
...(codexLaunchPreflightCommand ? [CMD_CODEX_LAUNCH_PREFLIGHT] : []),
|
||||
...(shellArgStartupCommand ? [shellArgStartupCommand] : [])
|
||||
]
|
||||
@@ -147,7 +162,7 @@ export function resolveWindowsShellLaunchArgs(
|
||||
}
|
||||
|
||||
if (shellBasename === 'powershell.exe' || shellBasename === 'pwsh.exe') {
|
||||
const powerShellCommand = getPowerShellEncodedCommand(nativeCwd, startupCommand)
|
||||
const powerShellCommand = getPowerShellEncodedCommand(nativeCwd, startupCommand, startupNotice)
|
||||
// Windows status requires OSC 133 prompt/readline hooks after profiles; encoded delivery follows powershell-osc133-bootstrap.ts MDE review.
|
||||
return {
|
||||
shellArgs: ['-NoLogo', '-NoExit', '-EncodedCommand', powerShellCommand.encodedCommand],
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import { resolveWindowsShellLaunchArgs } from './shell-args'
|
||||
import type { WindowsShellWslContext } from './shell-args'
|
||||
import { resolveWindowsPowerShellSpawnChain } from './powershell-executable'
|
||||
import { isPreviewPwshPath } from './pwsh-discovery'
|
||||
import {
|
||||
resolveWindowsPowerShellSpawnChain,
|
||||
type WindowsPowerShellResolveOptions
|
||||
} from './powershell-executable'
|
||||
buildWindowsPwshNotFoundNotices,
|
||||
type WindowsPwshNotFoundNotices
|
||||
} from './pwsh-not-found-notice'
|
||||
|
||||
export type WindowsShellSpawnAttempt = {
|
||||
shellPath: string
|
||||
@@ -19,14 +21,17 @@ function toAttempt(
|
||||
cwd: string,
|
||||
defaultCwd: string,
|
||||
wslContext: WindowsShellWslContext | undefined,
|
||||
startupCommand: string | undefined
|
||||
startupCommand: string | undefined,
|
||||
startupNotice?: string
|
||||
): WindowsShellSpawnAttempt {
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
shellPath,
|
||||
cwd,
|
||||
defaultCwd,
|
||||
wslContext,
|
||||
startupCommand
|
||||
startupCommand,
|
||||
undefined,
|
||||
startupNotice
|
||||
)
|
||||
return {
|
||||
shellPath,
|
||||
@@ -37,22 +42,56 @@ function toAttempt(
|
||||
}
|
||||
}
|
||||
|
||||
function pwshNotFoundNotice(
|
||||
fallbackShellPath: string,
|
||||
notices: WindowsPwshNotFoundNotices
|
||||
): string {
|
||||
const notice =
|
||||
pathWin32.basename(fallbackShellPath).toLowerCase() === 'powershell.exe'
|
||||
? notices.windowsPowerShell
|
||||
: notices.commandPrompt
|
||||
return `[${notice}]`
|
||||
}
|
||||
|
||||
/** Requested PowerShell → inbox PowerShell → cmd.exe, with absolute ConPTY-safe paths and per-shell argv.
|
||||
* Store alias stubs fail CreateProcessW (5); non-PowerShell requests retain their single-shell path. */
|
||||
export function buildWindowsPowerShellSpawnAttempts(args: {
|
||||
export async function buildWindowsPowerShellSpawnAttempts(args: {
|
||||
shellPath: string
|
||||
cwd: string
|
||||
defaultCwd: string
|
||||
wslContext?: WindowsShellWslContext
|
||||
startupCommand?: string
|
||||
resolveOptions?: WindowsPowerShellResolveOptions
|
||||
}): WindowsShellSpawnAttempt[] {
|
||||
/** The environment the terminal will get, so PATH lookups see what the shell will see. */
|
||||
env: NodeJS.ProcessEnv
|
||||
/** The shell setting as requested (name or path) and the PowerShell version setting. */
|
||||
requestedShell: string
|
||||
powerShellImplementation: string | undefined
|
||||
/** Translated by main; English when an older client did not send them. */
|
||||
pwshNotFoundNotices?: WindowsPwshNotFoundNotices
|
||||
}): Promise<WindowsShellSpawnAttempt[]> {
|
||||
const basename = pathWin32.basename(args.shellPath).toLowerCase()
|
||||
if (basename !== 'pwsh.exe' && basename !== 'powershell.exe') {
|
||||
return []
|
||||
}
|
||||
const chain = resolveWindowsPowerShellSpawnChain(basename, args.resolveOptions)
|
||||
const chain = await resolveWindowsPowerShellSpawnChain(basename, {
|
||||
env: args.env,
|
||||
settingKey: `${args.requestedShell}|${args.powerShellImplementation ?? ''}`,
|
||||
preferPreview: isPreviewPwshPath(args.requestedShell)
|
||||
})
|
||||
// A missing PowerShell 7 is announced by the fallback shell itself.
|
||||
const pwshMissing =
|
||||
basename === 'pwsh.exe' && pathWin32.basename(chain[0] ?? '').toLowerCase() !== 'pwsh.exe'
|
||||
const notices = pwshMissing
|
||||
? (args.pwshNotFoundNotices ?? buildWindowsPwshNotFoundNotices())
|
||||
: undefined
|
||||
return chain.map((candidate) =>
|
||||
toAttempt(candidate, args.cwd, args.defaultCwd, args.wslContext, args.startupCommand)
|
||||
toAttempt(
|
||||
candidate,
|
||||
args.cwd,
|
||||
args.defaultCwd,
|
||||
args.wslContext,
|
||||
args.startupCommand,
|
||||
notices ? pwshNotFoundNotice(candidate, notices) : undefined
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ type WindowsShellPathHydrationOptions = {
|
||||
hydrate?: () => Promise<HydrationResult>
|
||||
merge?: (segments: string[]) => string[]
|
||||
resolveGitBashPath?: (shell: string) => string | null
|
||||
resolvePowerShellChain?: (family: 'powershell.exe' | 'pwsh.exe') => string[]
|
||||
resolvePowerShellChain?: (
|
||||
family: 'powershell.exe' | 'pwsh.exe',
|
||||
settingKey: string
|
||||
) => string[] | Promise<string[]>
|
||||
warn?: (error: unknown) => void
|
||||
}
|
||||
|
||||
@@ -32,7 +35,9 @@ export function createWindowsShellPathHydration(options: WindowsShellPathHydrati
|
||||
const merge = options.merge ?? mergePathSegments
|
||||
const resolveGitBashPath = options.resolveGitBashPath ?? resolveWindowsGitBashShellPath
|
||||
const resolvePowerShellChain =
|
||||
options.resolvePowerShellChain ?? resolveWindowsPowerShellSpawnChain
|
||||
options.resolvePowerShellChain ??
|
||||
((family: 'powershell.exe' | 'pwsh.exe', settingKey: string) =>
|
||||
resolveWindowsPowerShellSpawnChain(family, { settingKey }))
|
||||
const warn =
|
||||
options.warn ??
|
||||
((error: unknown) => {
|
||||
@@ -41,10 +46,12 @@ export function createWindowsShellPathHydration(options: WindowsShellPathHydrati
|
||||
let generation = 0
|
||||
let ready = Promise.resolve()
|
||||
|
||||
const setConfiguredShell = (
|
||||
// PowerShell paths come from an async cached search; a newer request wins over a slower one.
|
||||
const setConfiguredShell = async (
|
||||
shell: string | null | undefined,
|
||||
implementation?: WindowsPowerShellImplementation
|
||||
): void => {
|
||||
implementation: WindowsPowerShellImplementation | undefined,
|
||||
requestGeneration: number
|
||||
): Promise<void> => {
|
||||
const requestedShell = shell?.trim() || 'powershell.exe'
|
||||
const basename = pathWin32.basename(requestedShell).toLowerCase()
|
||||
const shellFamily: WindowsPowerShellShellFamily =
|
||||
@@ -58,7 +65,13 @@ export function createWindowsShellPathHydration(options: WindowsShellPathHydrati
|
||||
configure(requestedShell, resolveGitBashPath(requestedShell), null)
|
||||
return
|
||||
}
|
||||
const spawnChain = resolvePowerShellChain(effectivePowerShell)
|
||||
const spawnChain = await resolvePowerShellChain(
|
||||
effectivePowerShell,
|
||||
`${requestedShell}|${implementation ?? ''}`
|
||||
)
|
||||
if (requestGeneration !== generation) {
|
||||
return
|
||||
}
|
||||
const profileShell = spawnChain[0] ?? 'cmd.exe'
|
||||
const fallbackShell = spawnChain.slice(1).find((candidate) => {
|
||||
const candidateBasename = pathWin32.basename(candidate).toLowerCase()
|
||||
@@ -70,13 +83,15 @@ export function createWindowsShellPathHydration(options: WindowsShellPathHydrati
|
||||
return {
|
||||
configure: (shell, implementation?: WindowsPowerShellImplementation) => {
|
||||
generation += 1
|
||||
setConfiguredShell(shell, implementation)
|
||||
const configured = setConfiguredShell(shell, implementation, generation)
|
||||
ready = ready.then(() => configured)
|
||||
},
|
||||
hydrate: (shell, implementation?: WindowsPowerShellImplementation) => {
|
||||
generation += 1
|
||||
const requestGeneration = generation
|
||||
setConfiguredShell(shell, implementation)
|
||||
const configured = setConfiguredShell(shell, implementation, requestGeneration)
|
||||
ready = ready.then(async () => {
|
||||
await configured
|
||||
if (requestGeneration !== generation) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -21820,7 +21820,11 @@
|
||||
"count_other": "{{count}} laufende Terminals"
|
||||
},
|
||||
"restartedInputNotSent": "Das Terminal wurde während der Eingabe neu gestartet. Daher hat es möglicherweise einen Teil der Eingabe nicht erhalten. Die neue Shell prüfen, bevor die Eingabe wiederholt wird.",
|
||||
"typingBeforeStartNotSent": "Einige Tasteneingaben während des Terminalstarts wurden nicht gesendet."
|
||||
"typingBeforeStartNotSent": "Einige Tasteneingaben während des Terminalstarts wurden nicht gesendet.",
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "Orca konnte PowerShell 7 (pwsh.exe) nicht finden, daher wurde in diesem Terminal stattdessen Windows PowerShell geöffnet.",
|
||||
"commandPrompt": "Orca konnte PowerShell 7 (pwsh.exe) nicht finden, daher wurde in diesem Terminal stattdessen die Eingabeaufforderung geöffnet."
|
||||
}
|
||||
},
|
||||
"fileExplorer": {
|
||||
"root": {
|
||||
|
||||
@@ -23324,6 +23324,10 @@
|
||||
"decline": "No thanks",
|
||||
"failed": "Could not turn on Claude Code theme sync",
|
||||
"failedDescription": "Turn it on in Settings."
|
||||
},
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "Orca could not find PowerShell 7 (pwsh.exe), so this terminal opened Windows PowerShell instead.",
|
||||
"commandPrompt": "Orca could not find PowerShell 7 (pwsh.exe), so this terminal opened Command Prompt instead."
|
||||
}
|
||||
},
|
||||
"fileExplorer": {
|
||||
|
||||
@@ -22026,6 +22026,10 @@
|
||||
"gotIt": "Entendido",
|
||||
"body": "Para ocultarlo, escriba /diff en Claude Code. Claude recuerda esa elección para sesiones posteriores.",
|
||||
"title": "Claude Code puede abrir su propio panel de diferencias al lado del chat en terminales anchas. Ese panel es de Claude, no de Orca."
|
||||
},
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "Orca no encontró PowerShell 7 (pwsh.exe), así que este terminal abrió Windows PowerShell en su lugar.",
|
||||
"commandPrompt": "Orca no encontró PowerShell 7 (pwsh.exe), así que este terminal abrió el Símbolo del sistema en su lugar."
|
||||
}
|
||||
},
|
||||
"quickOpen": {
|
||||
|
||||
@@ -22405,6 +22405,10 @@
|
||||
"gotIt": "J'ai compris",
|
||||
"title": "Claude Code peut ouvrir son propre panneau de différences à côté du chat dans les terminaux larges. Ce panneau appartient à Claude, pas à Orca.",
|
||||
"body": "Pour le masquer, tapez /diff dans Claude Code. Claude se souvient de ce choix pour les sessions ultérieures."
|
||||
},
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "Orca n'a pas trouvé PowerShell 7 (pwsh.exe), ce terminal a donc ouvert Windows PowerShell à la place.",
|
||||
"commandPrompt": "Orca n'a pas trouvé PowerShell 7 (pwsh.exe), ce terminal a donc ouvert l'invite de commandes à la place."
|
||||
}
|
||||
},
|
||||
"fileSearch": {
|
||||
|
||||
@@ -21726,6 +21726,10 @@
|
||||
"gotIt": "了解",
|
||||
"title": "Claude Code は、ワイドターミナルでチャットの横に独自の差分パネルを開くことができます。そのパネルは Claude のものであり、Orca のものではありません。",
|
||||
"body": "非表示にするには、Claude Code で /diff と入力してください。Claude はこの選択を今後のセッションでも記憶します。"
|
||||
},
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "Orca は PowerShell 7 (pwsh.exe) を見つけられなかったため、このターミナルでは代わりに Windows PowerShell を開きました。",
|
||||
"commandPrompt": "Orca は PowerShell 7 (pwsh.exe) を見つけられなかったため、このターミナルでは代わりにコマンド プロンプトを開きました。"
|
||||
}
|
||||
},
|
||||
"fileSearch": {
|
||||
|
||||
@@ -21718,6 +21718,10 @@
|
||||
"gotIt": "확인",
|
||||
"body": "숨기려면 Claude Code에 /diff를 입력하세요. Claude는 이후 세션을 위해 해당 선택을 기억합니다.",
|
||||
"title": "Claude Code는 넓은 터미널에서 채팅 옆에 자체 diff 패널을 열 수 있습니다. 이 패널은 Orca가 아닌 Claude의 패널입니다."
|
||||
},
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "Orca에서 PowerShell 7(pwsh.exe)을 찾을 수 없어 이 터미널에서 대신 Windows PowerShell을 열었습니다.",
|
||||
"commandPrompt": "Orca에서 PowerShell 7(pwsh.exe)을 찾을 수 없어 이 터미널에서 대신 명령 프롬프트를 열었습니다."
|
||||
}
|
||||
},
|
||||
"fileSearch": {
|
||||
|
||||
@@ -22797,7 +22797,11 @@
|
||||
"count_one": "{{count}} terminal em execução"
|
||||
},
|
||||
"restartedInputNotSent": "O terminal reiniciou enquanto você digitava, então parte da entrada pode não ter chegado a ele. Verifique o novo shell antes de digitar novamente.",
|
||||
"typingBeforeStartNotSent": "Algumas teclas digitadas enquanto o terminal iniciava não foram enviadas."
|
||||
"typingBeforeStartNotSent": "Algumas teclas digitadas enquanto o terminal iniciava não foram enviadas.",
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "O Orca não encontrou o PowerShell 7 (pwsh.exe), então este terminal abriu o Windows PowerShell no lugar.",
|
||||
"commandPrompt": "O Orca não encontrou o PowerShell 7 (pwsh.exe), então este terminal abriu o Prompt de Comando no lugar."
|
||||
}
|
||||
},
|
||||
"fileExplorer": {
|
||||
"root": {
|
||||
|
||||
@@ -22521,7 +22521,11 @@
|
||||
"count_many": "{{count}} работающих терминалов"
|
||||
},
|
||||
"restartedInputNotSent": "Терминал перезапустился во время ввода, поэтому часть текста могла не дойти. Проверьте новую оболочку, прежде чем вводить текст снова.",
|
||||
"typingBeforeStartNotSent": "Часть клавиш, нажатых во время запуска терминала, не была отправлена."
|
||||
"typingBeforeStartNotSent": "Часть клавиш, нажатых во время запуска терминала, не была отправлена.",
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "Orca не удалось найти PowerShell 7 (pwsh.exe), поэтому в этом терминале вместо него открыт Windows PowerShell.",
|
||||
"commandPrompt": "Orca не удалось найти PowerShell 7 (pwsh.exe), поэтому в этом терминале вместо него открыта командная строка."
|
||||
}
|
||||
},
|
||||
"fileExplorer": {
|
||||
"root": {
|
||||
|
||||
@@ -21790,7 +21790,11 @@
|
||||
"count_other": "Çalışan {{count}} terminal"
|
||||
},
|
||||
"restartedInputNotSent": "Siz yazarken terminal yeniden başlatıldı, bu nedenle girdilerinizin bir kısmı terminale ulaşmamış olabilir. Yeniden yazmadan önce yeni kabuğu kontrol edin.",
|
||||
"typingBeforeStartNotSent": "Terminal başlatılırken yazılan bazı tuşlar gönderilmedi."
|
||||
"typingBeforeStartNotSent": "Terminal başlatılırken yazılan bazı tuşlar gönderilmedi.",
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "Orca, PowerShell 7'yi (pwsh.exe) bulamadı, bu nedenle bu terminal bunun yerine Windows PowerShell'i açtı.",
|
||||
"commandPrompt": "Orca, PowerShell 7'yi (pwsh.exe) bulamadı, bu nedenle bu terminal bunun yerine Komut İstemi'ni açtı."
|
||||
}
|
||||
},
|
||||
"fileExplorer": {
|
||||
"root": {
|
||||
|
||||
@@ -21496,7 +21496,11 @@
|
||||
"count_other": "{{count}} 個正在執行的終端機"
|
||||
},
|
||||
"restartedInputNotSent": "終端機在您輸入時重新啟動,部分輸入可能未送達。請先檢視新的 shell,再重新輸入。",
|
||||
"typingBeforeStartNotSent": "終端機啟動期間輸入的部分按鍵未送出。"
|
||||
"typingBeforeStartNotSent": "終端機啟動期間輸入的部分按鍵未送出。",
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "Orca 找不到 PowerShell 7 (pwsh.exe),因此此終端機改為開啟 Windows PowerShell。",
|
||||
"commandPrompt": "Orca 找不到 PowerShell 7 (pwsh.exe),因此此終端機改為開啟命令提示字元。"
|
||||
}
|
||||
},
|
||||
"fileExplorer": {
|
||||
"root": {
|
||||
|
||||
@@ -22066,6 +22066,10 @@
|
||||
"gotIt": "知道了",
|
||||
"title": "Claude Code 在较宽的终端中会在聊天旁打开自己的差异面板,那个面板属于 Claude,而不是 Orca",
|
||||
"body": "要隐藏它,请在 Claude Code 中输入 /diff,Claude 会在之后的会话中记住这一选择"
|
||||
},
|
||||
"windowsPwshNotFound": {
|
||||
"windowsPowerShell": "Orca 未找到 PowerShell 7 (pwsh.exe),因此此终端改为打开了 Windows PowerShell。",
|
||||
"commandPrompt": "Orca 未找到 PowerShell 7 (pwsh.exe),因此此终端改为打开了命令提示符。"
|
||||
}
|
||||
},
|
||||
"fileSearch": {
|
||||
|
||||
Reference in New Issue
Block a user