From df8be84690ba450c584b6d1aeb9dbf96fdbc5116 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 6 Jun 2026 02:05:29 +0000 Subject: [PATCH] feat: support Windows cmd stdin scripts --- scripts/src/help.ts | 2 ++ scripts/src/ssh.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/scripts/src/help.ts b/scripts/src/help.ts index b99ade0b..a79530b9 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -168,6 +168,7 @@ export function sshHelp(): unknown { "trans find [--contains TEXT] [--limit N]", "trans glob [--root DIR] [--pattern PATTERN]", "trans D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch", + "trans D601:win/c/test cmd <<'CMD'", "trans D601:win cmd ver", "trans D601:win/c/test cmd cd", "trans D601:win skills [--scope agents|codex|all] [--limit N]", @@ -191,6 +192,7 @@ export function sshHelp(): unknown { notes: [ "trans --help and trans --help print this JSON help and never open an interactive session; the underlying ssh subcommand keeps the same help behavior.", "For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.", + "For Windows routes, prefer `trans :win// cmd <<'CMD'` for multi-step cmd.exe logic; `cmd` with no command-line arguments reads the UTF-8 batch body from stdin, injects UTF-8/Python encoding defaults, runs it from a temp .cmd file, and deletes the temp file.", "`argv` executes direct argv tokens only: `trans argv ls -la` is valid, but `trans argv 'ls -la'` is rejected because the single string would be treated as an executable path; use `script -- 'ls -la'` for one-line shell logic.", "For one-line remote shell logic without a heredoc, use `script -- ''`; outer shell operators written outside trans, such as `trans G14:/repo sed ... && sed ...`, are parsed by the local shell before UniDesk starts and therefore cannot be redirected by the CLI. The explicit `shell ''` operation remains available for the same sh -c path.", "When a one-line shell command is easier to type through the script path, `script -- ''` runs that single string through the remote shell without waiting for stdin. When `script --` is followed by multiple tokens, it stays a direct argv form for commands such as `trans D601:/work script -- sed -n '1,20p' file`.", diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 734a5b85..d5f7ba2c 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -1052,7 +1052,13 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs throw new Error(`unsupported ssh win operation: ${operation}; use ssh ${route.providerId}:win cmd , ssh ${route.providerId}:win apply-patch, or ssh ${route.providerId}:win skills`); } const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1); - if (commandArgs.length === 0) throw new Error(`ssh ${route.raw} cmd requires a command line, for example: ssh ${route.providerId}:win cmd ver`); + if (commandArgs.length === 0) { + return { + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdStdinLauncherScript(route.workspace)), + requiresStdin: true, + invocationKind: "helper", + }; + } return { remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript(buildWindowsCmdLine(commandArgs.join(" "), route.workspace))), requiresStdin: false, @@ -1163,6 +1169,38 @@ function buildWindowsCmdLauncherScript(cmdLine: string): string { ].join(" "); } +function buildWindowsCmdStdinLauncherScript(cwd: string | null): string { + const prefixLines = [ + "@echo off", + "chcp 65001>nul", + 'set "PYTHONUTF8=1"', + 'set "PYTHONIOENCODING=utf-8"', + ...(cwd === null ? [] : [`cd /d ${windowsCmdQuote(cwd)}`]), + ]; + return [ + "$ErrorActionPreference = 'Stop';", + "$ProgressPreference = 'SilentlyContinue';", + "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();", + "$OutputEncoding = [System.Text.UTF8Encoding]::new();", + "$env:PYTHONUTF8 = '1';", + "$env:PYTHONIOENCODING = 'utf-8';", + "$script = [Console]::In.ReadToEnd();", + "if ([string]::IsNullOrWhiteSpace($script)) { [Console]::Error.WriteLine('ssh win cmd requires a command line or stdin batch script'); exit 2 }", + `$prefix = ${powerShellSingleQuote(`${prefixLines.join("\r\n")}\r\n`)};`, + "$temp = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), ('unidesk-win-cmd-' + [guid]::NewGuid().ToString('N') + '.cmd'));", + "try {", + " [System.IO.File]::WriteAllText($temp, $prefix + $script, [System.Text.UTF8Encoding]::new($false));", + " $cmdArg = '\"' + $temp + '\"';", + ` & ${powerShellSingleQuote(windowsCmdExeNativePath)} /d /s /c $cmdArg;`, + " $code = $LASTEXITCODE;", + "} finally {", + " Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue;", + "}", + "exit $code;", + ].join(" "); +} + export function buildWindowsPowerShellInvocation(script: string): string { return shellArgv([ windowsPowerShellExePath, @@ -2559,7 +2597,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers), cwd: sshRoutePayloadCwd(invocation.route), tty: parsed.remoteCommand === null, - stdinEotOnEnd: parsed.remoteCommand !== null, + stdinEotOnEnd: parsed.remoteCommand !== null && parsed.requiresStdin !== true, openTimeoutMs, runtimeTimeoutMs, cols: size.cols,