7210cc2517
Co-Authored-By: Codex <noreply@openai.com>
2064 lines
92 KiB
TypeScript
2064 lines
92 KiB
TypeScript
import { isApplyPatchV2HelpArgs } from "./apply-patch-v2";
|
|
import { isSshFileTransferOperation } from "./ssh-file-transfer";
|
|
import { isWindowsFsReadOnlyOperation, windowsFsReadOnlyScript } from "./ssh-windows-fs";
|
|
import { remoteApplyPatchSource, remoteGlobSource, remoteSkillDiscoverSource } from "./ssh-remote-tools";
|
|
import { readTransHostProxyEnvRule, type TransHostProxyEnvRule } from "./trans-host-proxy";
|
|
|
|
export interface ParsedSshArgs {
|
|
remoteCommand: string | null;
|
|
requiresStdin: boolean;
|
|
invocationKind: SshInvocationKind;
|
|
stdinPrefix?: string;
|
|
stdinSuffix?: string;
|
|
requiredHelpers?: SshHelperName[];
|
|
}
|
|
|
|
export type SshInvocationKind = "interactive" | "argv" | "helper" | "ssh-like";
|
|
export type SshHelperName = "apply_patch" | "glob" | "skill-discover";
|
|
|
|
export interface ParsedSshRoute {
|
|
providerId: string;
|
|
plane: "host" | "k3s" | "win";
|
|
entry: string | null;
|
|
namespace: string | null;
|
|
resource: string | null;
|
|
container: string | null;
|
|
workspace: string | null;
|
|
raw: string;
|
|
}
|
|
|
|
export interface ParsedSshInvocation {
|
|
providerId: string;
|
|
route: ParsedSshRoute;
|
|
parsed: ParsedSshArgs;
|
|
}
|
|
|
|
export interface EffectiveApplyPatchV2Invocation {
|
|
invocation: ParsedSshInvocation;
|
|
argv: string[];
|
|
}
|
|
|
|
export interface SshCaptureResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export type SshCaptureBackend = "local-backend-core-broker" | "remote-frontend-websocket";
|
|
|
|
export interface SshCaptureBackendPlan {
|
|
backend: SshCaptureBackend;
|
|
remoteHost: string | null;
|
|
reason: string;
|
|
localBackendCore: {
|
|
dockerExecutable: boolean;
|
|
backendCoreContainer: boolean;
|
|
error: string | null;
|
|
source?: string;
|
|
};
|
|
}
|
|
|
|
export interface SshFailureHint {
|
|
code: "ssh-like-command-friction";
|
|
providerId: string;
|
|
trigger: "timeout-or-kex" | "exit-255";
|
|
exitCode: number;
|
|
message: string;
|
|
try: string;
|
|
triage: string;
|
|
note: string;
|
|
}
|
|
|
|
export interface SshRuntimeTimingHint {
|
|
code: "ssh-runtime-timing";
|
|
level: "info" | "warning";
|
|
providerId: string;
|
|
route: string;
|
|
transport: "backend-core-broker" | "frontend-websocket";
|
|
invocationKind: SshInvocationKind;
|
|
exitCode: number;
|
|
elapsedMs: number;
|
|
elapsedSeconds: number;
|
|
thresholdMs: number;
|
|
slow: boolean;
|
|
message: string;
|
|
note: string;
|
|
}
|
|
|
|
export interface SshRuntimeTimeoutHint {
|
|
code: "ssh-runtime-timeout";
|
|
level: "warning";
|
|
providerId: string;
|
|
route: string;
|
|
transport: "backend-core-broker" | "frontend-websocket";
|
|
invocationKind: SshInvocationKind;
|
|
timeoutMs: number;
|
|
timeoutSeconds: number;
|
|
message: string;
|
|
action: string;
|
|
note: string;
|
|
}
|
|
|
|
export interface RemovedShellAliasReplacementExamples {
|
|
posixSh: string;
|
|
bash: string;
|
|
templatePosixSh: string;
|
|
templateBash: string;
|
|
}
|
|
|
|
export class SshRemovedShellAliasError extends Error {
|
|
code = "ssh-removed-shell-alias";
|
|
level = "error";
|
|
entrypoint: string;
|
|
route: string;
|
|
operation: string;
|
|
replacementExamples: RemovedShellAliasReplacementExamples;
|
|
migrationHint: string;
|
|
note = "`sh` runs POSIX /bin/sh; `bash` is only for Bash-specific syntax. The removed alias intentionally no longer chooses a hidden shell dialect.";
|
|
|
|
constructor(operation: string, route: string, operationArgs: string[]) {
|
|
const entrypoint = sshDisplayEntrypoint();
|
|
const replacementExamples = removedShellAliasReplacementExamples(entrypoint, route, operationArgs);
|
|
super(`${entrypoint} ${route} ${operation} operation has been removed because it hides the shell dialect; use explicit sh for POSIX /bin/sh or bash for Bash syntax`);
|
|
this.name = "SshRemovedShellAliasError";
|
|
this.entrypoint = entrypoint;
|
|
this.route = route;
|
|
this.operation = operation;
|
|
this.replacementExamples = replacementExamples;
|
|
this.migrationHint = `Rewrite as ${replacementExamples.posixSh} for POSIX shell, or ${replacementExamples.bash} for Bash syntax.`;
|
|
}
|
|
}
|
|
|
|
export class SshWindowsPowerShellLocalExpansionError extends Error {
|
|
code = "ssh-windows-powershell-local-expansion";
|
|
level = "error";
|
|
entrypoint: string;
|
|
route: string;
|
|
operation = "ps";
|
|
replacementExamples: { oneLine: string; pipeline: string; stdin: string };
|
|
migrationHint: string;
|
|
note = "Local Bash expands $p and $_ inside double quotes before trans receives the PowerShell source.";
|
|
|
|
constructor(route: string) {
|
|
const entrypoint = sshDisplayEntrypoint();
|
|
const replacementExamples = {
|
|
oneLine: `${entrypoint} ${route} ps '$p = Start-Process python -PassThru; $p.Id'`,
|
|
pipeline: `${entrypoint} ${route} ps 'Get-Process | Where-Object { $_.CPU -gt 0 }'`,
|
|
stdin: `${entrypoint} ${route} ps <<'PS'`,
|
|
};
|
|
super("ssh win ps source looks like a PowerShell variable was expanded by the local shell before trans received it");
|
|
this.name = "SshWindowsPowerShellLocalExpansionError";
|
|
this.entrypoint = entrypoint;
|
|
this.route = route;
|
|
this.replacementExamples = replacementExamples;
|
|
this.migrationHint = `Wrap the complete PowerShell source in local single quotes, for example: ${replacementExamples.oneLine}. Use a quoted PS heredoc for multiline source.`;
|
|
}
|
|
}
|
|
|
|
export interface SshStdoutTruncationHint {
|
|
code: "ssh-stdout-truncated" | "ssh-stderr-truncated";
|
|
level: "warning";
|
|
stream: "stdout" | "stderr";
|
|
providerId: string;
|
|
route: string;
|
|
transport: "backend-core-broker" | "frontend-websocket";
|
|
invocationKind: SshInvocationKind;
|
|
thresholdBytes: number;
|
|
observedBytesAtTruncation: number;
|
|
forwardedBytes: number;
|
|
dumpPath: string | null;
|
|
dumpError: string | null;
|
|
disclosurePolicy: {
|
|
name: "unified-cli-dump-preview";
|
|
configPath: string;
|
|
maxPreviewBytes: number;
|
|
dumpDir: string;
|
|
};
|
|
recommendedRerun: string[];
|
|
message: string;
|
|
action: string;
|
|
note: string;
|
|
}
|
|
|
|
export interface SshStreamTruncationSummary {
|
|
stream: "stdout" | "stderr";
|
|
thresholdBytes: number;
|
|
observedBytesAtTruncation: number;
|
|
forwardedBytes: number;
|
|
dumpPath: string | null;
|
|
dumpError: string | null;
|
|
}
|
|
|
|
export interface SshTruncationCompletionSummary {
|
|
code: "ssh-truncation-summary";
|
|
level: "info";
|
|
providerId: string;
|
|
route: string;
|
|
transport: "backend-core-broker" | "frontend-websocket";
|
|
invocationKind: SshInvocationKind;
|
|
exitCode: number;
|
|
timedOut: boolean;
|
|
elapsedMs: number;
|
|
elapsedSeconds: number;
|
|
commandOmitted: true;
|
|
stdout: SshStreamTruncationSummary | null;
|
|
stderr: SshStreamTruncationSummary | null;
|
|
message: string;
|
|
action: string;
|
|
}
|
|
|
|
export type SshTcpPoolFailureKind =
|
|
| "provider-data-channel-closed"
|
|
| "provider-data-channel-missing"
|
|
| "provider-data-pool-exhausted";
|
|
|
|
export interface SshTcpPoolHint {
|
|
code: "ssh-tcp-pool-transient";
|
|
level: "warning";
|
|
providerId: string;
|
|
route: string;
|
|
transport: "backend-core-broker" | "frontend-websocket";
|
|
invocationKind: SshInvocationKind;
|
|
failureKind: SshTcpPoolFailureKind;
|
|
exitCode: number;
|
|
message: string;
|
|
action: string;
|
|
retry: string;
|
|
diagnostics: {
|
|
poolStatus: string;
|
|
fullHealth: string;
|
|
};
|
|
note: string;
|
|
}
|
|
|
|
const argvQuotedSshSubcommands = new Set(["git", "rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]);
|
|
const nativeK3sKubeconfig = "/etc/rancher/k3s/k3s.yaml";
|
|
const windowsBridgeCwd = "/mnt/c/Windows";
|
|
const windowsPowerShellExePath = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
|
|
const windowsCmdExeNativePath = "C:\\Windows\\System32\\cmd.exe";
|
|
const defaultSshSlowWarningMs = 10_000;
|
|
const defaultSshRuntimeTimeoutMs = 60_000;
|
|
const maxSshRuntimeTimeoutMs = 60_000;
|
|
const defaultSshBackendCoreDetectTimeoutMs = 15_000;
|
|
const maxSshBackendCoreDetectTimeoutMs = 60_000;
|
|
const minSshStdoutStreamMaxBytes = 4 * 1024;
|
|
const maxSshStdoutStreamMaxBytes = 16 * 1024 * 1024;
|
|
export const sshShellCompatibilityPrelude = 'printf(){ if [ "${1+x}" = x ] && [ "$1" = "-v" ] && [ -n "${BASH_VERSION:-}" ]; then command printf "$@"; return $?; fi; if [ "${1+x}" = x ] && [ "$1" = "--" ]; then shift; fi; command printf -- "$@"; }';
|
|
export const sshUserToolPathPrelude = [
|
|
'for unidesk_path_dir in "$HOME/.bun/bin" "$HOME/.local/bin" "$HOME/bin" "/root/.bun/bin"; do',
|
|
' [ -d "$unidesk_path_dir" ] || continue',
|
|
' case ":$PATH:" in',
|
|
' *":$unidesk_path_dir:"*) ;;',
|
|
' *) PATH="$unidesk_path_dir:$PATH" ;;',
|
|
" esac",
|
|
"done",
|
|
"export PATH",
|
|
].join("\n");
|
|
const k3sResourceKindAliases = new Set(["pod", "po", "pods", "deployment", "deploy", "deployments", "statefulset", "sts", "daemonset", "ds", "job", "jobs"]);
|
|
const legacyK3sOperationRouteSegments = new Set([
|
|
"guard",
|
|
"kubectl",
|
|
"exec",
|
|
"script",
|
|
"shell",
|
|
"sh",
|
|
"bash",
|
|
"apply-patch",
|
|
"apply-patch-v1",
|
|
"patch",
|
|
"patch-v1",
|
|
"v2",
|
|
"logs",
|
|
"get",
|
|
"describe",
|
|
"top",
|
|
"rollout",
|
|
"wait",
|
|
"config",
|
|
"version",
|
|
"cluster-info",
|
|
"auth",
|
|
"api-resources",
|
|
"api-versions",
|
|
]);
|
|
|
|
const sshOptionsWithValue = new Set([
|
|
"-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w",
|
|
]);
|
|
|
|
export function isSshSkillDiscoveryArgs(args: string[]): boolean {
|
|
const subcommand = args[0] ?? "";
|
|
return subcommand === "skills" || subcommand === "skill-discover" || subcommand === "discover-skills" || (subcommand === "skill" && args[1] === "discover");
|
|
}
|
|
|
|
export function parseSshArgs(args: string[], routeRaw = "<route>"): ParsedSshArgs {
|
|
const subcommand = args[0] ?? "";
|
|
if (isSshFileTransferOperation(args)) {
|
|
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
if (isSshSkillDiscoveryArgs(args)) {
|
|
const toolArgs = subcommand === "skill" ? ["skill-discover", ...args.slice(2)] : ["skill-discover", ...args.slice(1)];
|
|
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false, invocationKind: "helper", requiredHelpers: ["skill-discover"] };
|
|
}
|
|
if (subcommand === "apply-patch") {
|
|
if (isApplyPatchV2HelpArgs(args.slice(1))) {
|
|
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
|
|
}
|
|
if (subcommand === "apply-patch-v1") {
|
|
const toolArgs = ["apply_patch", ...args.slice(1)];
|
|
return { remoteCommand: shellArgv(toolArgs), requiresStdin: true, invocationKind: "helper", requiredHelpers: ["apply_patch"] };
|
|
}
|
|
if (subcommand === "patch" || subcommand === "patch-v1" || subcommand === "v2") {
|
|
throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper");
|
|
}
|
|
if (subcommand === "py") {
|
|
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
|
|
}
|
|
if (subcommand === "playwright") {
|
|
throw new Error("The remote Playwright operation has been removed; use `bun scripts/cli.ts web-probe screenshot ...` or `web-probe sentinel dashboard screenshot ...` for remote screenshots");
|
|
}
|
|
if (subcommand === "script" || subcommand === "shell") {
|
|
throw removedShellAliasError(subcommand, routeRaw, args.slice(1));
|
|
}
|
|
if (subcommand === "sh" || subcommand === "bash") {
|
|
return buildShellCommand(args.slice(1), subcommand, `ssh ${subcommand}`);
|
|
}
|
|
if (subcommand === "argv" || subcommand === "exec") {
|
|
const toolArgs = args.slice(1);
|
|
if (toolArgs.length === 0) throw new Error(`ssh ${subcommand} requires a command`);
|
|
validateDirectArgvCommand(subcommand, toolArgs);
|
|
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false, invocationKind: "argv" };
|
|
}
|
|
if (subcommand === "find") {
|
|
return { remoteCommand: buildFindCommand(args.slice(1)), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
if (subcommand === "glob") {
|
|
return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false, invocationKind: "helper", requiredHelpers: ["glob"] };
|
|
}
|
|
if (subcommand === "k3s") {
|
|
throw new Error("ssh k3s shorthand is unsupported; put k3s in the route, for example: trans D601:k3s kubectl get nodes");
|
|
}
|
|
if (argvQuotedSshSubcommands.has(subcommand)) {
|
|
return { remoteCommand: shellArgv(args), requiresStdin: false, invocationKind: "argv" };
|
|
}
|
|
const remote: string[] = [];
|
|
let remoteStarted = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (remoteStarted) {
|
|
remote.push(arg);
|
|
continue;
|
|
}
|
|
if (arg === "--") {
|
|
remoteStarted = true;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-") && arg !== "-") {
|
|
if (sshOptionsWithValue.has(arg) && index + 1 < args.length) index += 1;
|
|
continue;
|
|
}
|
|
remoteStarted = true;
|
|
remote.push(arg);
|
|
}
|
|
return {
|
|
remoteCommand: remote.length === 0 ? null : shellArgv(remote),
|
|
requiresStdin: false,
|
|
invocationKind: remote.length === 0 ? "interactive" : "ssh-like",
|
|
};
|
|
}
|
|
|
|
export function normalizeSshOperationArgs(args: string[]): string[] {
|
|
return args[0] === "--" ? args.slice(1) : args;
|
|
}
|
|
|
|
export function sshRouteSeparatorCompatibilityHint(rawArgs: string[], normalizedArgs: string[]): string {
|
|
if (rawArgs === normalizedArgs || rawArgs[0] !== "--") return "";
|
|
const operation = normalizedArgs[0] ?? "";
|
|
const operationText = operation.length === 0 ? "operation" : `operation \`${operation}\``;
|
|
return `UNIDESK_SSH_HINT route-level -- is ignored before ${operationText}; canonical form is \`trans <route> ${normalizedArgs.join(" ")}\`. Keep -- only inside operations such as \`sh -- <command>\`, \`bash -- <command>\`, or \`exec -- <command>\`.\n`;
|
|
}
|
|
|
|
function validateDirectArgvCommand(commandName: string, toolArgs: string[]): void {
|
|
if (toolArgs.length !== 1) return;
|
|
const command = toolArgs[0] ?? "";
|
|
if (!looksLikeShellCommandString(command)) return;
|
|
throw new Error(
|
|
`ssh ${commandName} received one shell-like command string; ${commandName} executes a single process and treats that string as the executable path. ` +
|
|
`Use \`trans <route> sh -- ${shellQuote(command)}\` or \`trans <route> bash -- ${shellQuote(command)}\` for one-line shell logic, or split argv tokens, for example \`trans <route> ${commandName} ls -la\`.`
|
|
);
|
|
}
|
|
|
|
function looksLikeShellCommandString(value: string): boolean {
|
|
return /\s/u.test(value) || /&&|\|\||[;|<>]/u.test(value);
|
|
}
|
|
|
|
export function parseSshInvocation(target: string, args: string[]): ParsedSshInvocation {
|
|
const route = parseSshRoute(target);
|
|
const operationArgs = normalizeSshOperationArgs(args);
|
|
if (route.plane === "k3s") {
|
|
return { providerId: route.providerId, route, parsed: parseK3sRouteArgs(route, operationArgs) };
|
|
}
|
|
if (route.plane === "win") {
|
|
return { providerId: route.providerId, route, parsed: parseWinRouteArgs(route, operationArgs) };
|
|
}
|
|
if ((operationArgs[0] ?? "") === "k3s") {
|
|
throw new Error(`ssh k3s shorthand is unsupported; use route syntax instead: trans ${route.providerId}:k3s ${operationArgs.slice(1).join(" ")}`.trim());
|
|
}
|
|
const parsed = parseSshArgs(operationArgs, route.raw);
|
|
return { providerId: route.providerId, route, parsed: withTransHostEnvironment(route, parsed) };
|
|
}
|
|
|
|
export function parseSshRoute(target: string): ParsedSshRoute {
|
|
if (!target) throw new Error("ssh requires provider id, for example: trans D601");
|
|
const firstColon = target.indexOf(":");
|
|
if (firstColon < 0) {
|
|
return hostSshRoute(target, target, null);
|
|
}
|
|
const providerId = target.slice(0, firstColon);
|
|
const tail = target.slice(firstColon + 1);
|
|
if (!providerId) throw new Error("ssh route requires a provider id before ':'");
|
|
if (tail.length === 0) {
|
|
return hostSshRoute(providerId, target, null);
|
|
}
|
|
if (tail.startsWith("/")) {
|
|
return hostSshRoute(providerId, target, tail);
|
|
}
|
|
if (tail === "win32" || tail.startsWith("win32/") || tail.startsWith("win32:")) {
|
|
throw new Error(`unsupported ssh route plane: win32; use ${providerId}:win or ${providerId}:win/c/path`);
|
|
}
|
|
if (tail === "win" || tail.startsWith("win/")) {
|
|
return winSshRoute(providerId, target, parseWinRouteWorkspace(providerId, tail));
|
|
}
|
|
if (tail.startsWith("win:")) {
|
|
throw new Error(`ssh win workspace route uses slash syntax, for example: trans ${providerId}:win/c/test cmd cd`);
|
|
}
|
|
const [plane, ...rest] = tail.split(":");
|
|
if (plane === undefined || plane.length === 0 || plane === "host") {
|
|
const workspace = rest.length > 0 ? rest.join(":") : null;
|
|
if (workspace !== null && !workspace.startsWith("/")) throw new Error("ssh host workspace route requires an absolute path after provider:host:");
|
|
return hostSshRoute(providerId, target, workspace);
|
|
}
|
|
if (plane !== "k3s") throw new Error(`unsupported ssh route plane: ${plane}`);
|
|
rejectK3sSlashKindRouteSegments(target, rest);
|
|
const normalizedRest = normalizeK3sRouteRestSegments(rest);
|
|
const [first, second, third, fourth] = normalizedRest;
|
|
const operationInRoute = [first, second, third].map((segment) => segment === undefined ? undefined : routeSegmentHead(segment)).find((segment) => segment !== undefined && legacyK3sOperationRouteSegments.has(segment));
|
|
if (operationInRoute !== undefined) throw new Error(k3sOperationInRouteMessage(target, operationInRoute));
|
|
if (fourth !== undefined) throw new Error("ssh k3s target route supports at most provider:k3s:namespace:resource:container");
|
|
const targetRoute = parseK3sRouteTargetSegments(second ?? null, third ?? null);
|
|
return {
|
|
providerId,
|
|
plane: "k3s",
|
|
entry: null,
|
|
namespace: first && first.length > 0 ? first : null,
|
|
resource: targetRoute.resource,
|
|
container: targetRoute.container,
|
|
workspace: targetRoute.workspace,
|
|
raw: target,
|
|
};
|
|
}
|
|
|
|
function hostSshRoute(providerId: string, raw: string, workspace: string | null): ParsedSshRoute {
|
|
return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, workspace, raw };
|
|
}
|
|
|
|
function winSshRoute(providerId: string, raw: string, workspace: string | null): ParsedSshRoute {
|
|
return { providerId, plane: "win", entry: null, namespace: null, resource: null, container: null, workspace, raw };
|
|
}
|
|
|
|
function parseWinRouteWorkspace(providerId: string, tail: string): string | null {
|
|
if (tail === "win") return null;
|
|
const suffix = tail.slice("win/".length);
|
|
const slashIndex = suffix.indexOf("/");
|
|
const drive = slashIndex < 0 ? suffix : suffix.slice(0, slashIndex);
|
|
if (!/^[A-Za-z]$/u.test(drive)) {
|
|
throw new Error(`ssh win workspace route requires a drive letter, for example: ssh ${providerId}:win/c/test cmd cd`);
|
|
}
|
|
const rest = slashIndex < 0 ? "" : suffix.slice(slashIndex + 1);
|
|
const segments = rest.split("/").filter((segment) => segment.length > 0);
|
|
return `${drive.toUpperCase()}:\\${segments.join("\\")}`;
|
|
}
|
|
|
|
function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
|
|
const operation = args[0] ?? "";
|
|
if (operation.length === 0) {
|
|
throw new Error(`ssh ${route.raw} requires a Windows operation, for example: ssh ${route.providerId}:win cmd ver or ssh ${route.providerId}:win skills`);
|
|
}
|
|
if (operation === "win") {
|
|
throw new Error(windowsRouteRepeatedWinOperationMessage(route, args.slice(1)));
|
|
}
|
|
if (operation === "upload" || operation === "download") {
|
|
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
if (operation === "apply-patch") {
|
|
if (isApplyPatchV2HelpArgs(args.slice(1))) {
|
|
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
|
|
}
|
|
if (operation === "apply-patch-v1") {
|
|
throw new Error(`ssh ${route.raw} apply-patch-v1 is not supported for Windows routes; use apply-patch v2`);
|
|
}
|
|
if (operation === "patch" || operation === "patch-v1" || operation === "v2") {
|
|
throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper");
|
|
}
|
|
if (operation === "skills" || operation === "skill-discover" || operation === "discover-skills") {
|
|
return {
|
|
remoteCommand: buildWindowsPowerShellInvocation(buildWindowsSkillsDiscoveryScript(args.slice(1))),
|
|
requiresStdin: false,
|
|
invocationKind: "helper",
|
|
};
|
|
}
|
|
if (operation === "playwright") {
|
|
throw new Error("The remote Playwright operation has been removed; use `bun scripts/cli.ts web-probe screenshot ...` or `web-probe sentinel dashboard screenshot ...` for remote screenshots");
|
|
}
|
|
if (isWindowsFsReadOnlyOperation(operation)) {
|
|
return {
|
|
remoteCommand: buildWindowsPowerShellInvocation(windowsFsReadOnlyScript(route.workspace, operation, args.slice(1))),
|
|
requiresStdin: false,
|
|
invocationKind: "helper",
|
|
};
|
|
}
|
|
if (operation === "git") {
|
|
return parseWindowsGitInvocation(route, args.slice(1));
|
|
}
|
|
if (operation === "ps" || operation === "powershell" || operation === "powershell.exe") {
|
|
const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1);
|
|
if (commandArgs.length >= 2 && (commandArgs[0] === "-File" || commandArgs[0] === "-file")) {
|
|
const fileArgs = [commandArgs[1]?.replace(/\\/g, "/") ?? "", ...commandArgs.slice(2)];
|
|
return {
|
|
remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellArgvLauncherScript(fileArgs, route.workspace)),
|
|
requiresStdin: false,
|
|
invocationKind: "argv",
|
|
};
|
|
}
|
|
if (commandArgs.length === 0) {
|
|
return {
|
|
remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellStdinLauncherScript(route.workspace)),
|
|
requiresStdin: true,
|
|
invocationKind: "helper",
|
|
};
|
|
}
|
|
if (commandArgs.length > 1) {
|
|
return {
|
|
remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellArgvLauncherScript(commandArgs, route.workspace)),
|
|
requiresStdin: false,
|
|
invocationKind: "argv",
|
|
};
|
|
}
|
|
const command = commandArgs[0] ?? "";
|
|
if (looksLikeLocallyExpandedPowerShellSource(command)) throw new SshWindowsPowerShellLocalExpansionError(route.raw);
|
|
return {
|
|
remoteCommand: buildWindowsPowerShellInvocation(buildWindowsPowerShellInlineLauncherScript(command, route.workspace)),
|
|
requiresStdin: false,
|
|
invocationKind: "helper",
|
|
};
|
|
}
|
|
if (operation !== "cmd" && operation !== "cmd.exe") {
|
|
throw new Error(windowsUnsupportedOperationMessage(route, operation));
|
|
}
|
|
const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1);
|
|
if (commandArgs.length === 0) {
|
|
return {
|
|
remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdStdinLauncherScript(route.workspace)),
|
|
requiresStdin: true,
|
|
invocationKind: "helper",
|
|
};
|
|
}
|
|
return {
|
|
remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript(buildWindowsCmdLine(commandArgs.map((value) => windowsCmdArgument(value, route)).join(" "), route.workspace))),
|
|
requiresStdin: false,
|
|
invocationKind: "argv",
|
|
};
|
|
}
|
|
|
|
function looksLikeLocallyExpandedPowerShellSource(command: string): boolean {
|
|
return /(?:^|[;{}]\s*)=\s*[^=]/u.test(command);
|
|
}
|
|
|
|
function windowsRouteRepeatedWinOperationMessage(route: ParsedSshRoute, nextArgs: string[]): string {
|
|
const entrypoint = sshDisplayEntrypoint();
|
|
const suffix = nextArgs.length > 0 ? ` ${nextArgs.join(" ")}` : " ps";
|
|
return `unsupported ssh win operation: win; route ${route.raw} already selects the Windows plane. ` +
|
|
`Write the operation directly after the route, for example \`${entrypoint} ${route.raw}${suffix}\`, not \`${entrypoint} ${route.raw} win${suffix}\`.`;
|
|
}
|
|
|
|
function windowsUnsupportedOperationMessage(route: ParsedSshRoute, operation: string): string {
|
|
const entrypoint = sshDisplayEntrypoint();
|
|
const gitHint = operation === "sed" || operation === "bash" || operation === "sh"
|
|
? ` For repository commands on Windows, use \`${entrypoint} ${route.raw} git status --short --branch\`, \`${entrypoint} ${route.raw} git commit -m <message>\`, or wrap custom commands with \`${entrypoint} ${route.raw} ps <<'PS'\`.`
|
|
: ` If you meant to run a repository command, use \`${entrypoint} ${route.raw} git status --short --branch\`, \`${entrypoint} ${route.raw} git commit -m <message>\`, or wrap it with \`${entrypoint} ${route.raw} ps <<'PS'\`.`;
|
|
return `unsupported ssh win operation: ${operation}; Windows route operations are ps, cmd, skills, apply-patch, upload/download, git, and read-only fs helpers pwd|ls|cat|head|tail|stat|wc|rg.${gitHint}`;
|
|
}
|
|
|
|
function parseWindowsGitInvocation(route: ParsedSshRoute, gitArgs: string[]): ParsedSshArgs {
|
|
const subcommand = gitArgs[0] ?? "";
|
|
if (subcommand.length === 0) throw new Error(`ssh ${route.raw} git requires a git subcommand, for example: ${sshDisplayEntrypoint()} ${route.raw} git status --short --branch`);
|
|
if (subcommand === "exact-commit") {
|
|
return {
|
|
remoteCommand: buildWindowsPowerShellInvocation(windowsGitExactCommitScript(route, gitArgs.slice(1))),
|
|
requiresStdin: false,
|
|
invocationKind: "helper",
|
|
};
|
|
}
|
|
if (subcommand === "commit") validateWindowsGitCommitArgs(route, gitArgs);
|
|
const commandLine = ["git", ...gitArgs].map((value) => windowsCmdArgument(value, route)).join(" ");
|
|
return {
|
|
remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript(buildWindowsCmdLine(commandLine, route.workspace))),
|
|
requiresStdin: false,
|
|
invocationKind: "argv",
|
|
};
|
|
}
|
|
|
|
function windowsGitExactCommitScript(route: ParsedSshRoute, args: string[]): string {
|
|
const operation = args[0] ?? "";
|
|
if (!new Set(["status", "plan", "run", "gc-auto"]).has(operation)) {
|
|
throw new Error("ssh win git exact-commit requires status, plan, run, or gc-auto");
|
|
}
|
|
const paths: string[] = [];
|
|
let message = "";
|
|
let confirm = false;
|
|
for (let index = 1; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === "--path") {
|
|
const value = args[index + 1];
|
|
if (!value) throw new Error("git exact-commit --path requires a repository-relative path");
|
|
if (/^(?:[A-Za-z]:|[\\/])|(?:^|[\\/])\.\.(?:[\\/]|$)/u.test(value)) throw new Error("git exact-commit --path must stay inside the repository");
|
|
paths.push(value.replace(/\\/gu, "/"));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--message") {
|
|
const value = args[index + 1];
|
|
if (!value) throw new Error("git exact-commit --message requires a value");
|
|
message = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--confirm") {
|
|
confirm = true;
|
|
continue;
|
|
}
|
|
throw new Error(`unsupported git exact-commit option: ${arg}`);
|
|
}
|
|
if ((operation === "plan" || operation === "run") && paths.length === 0) throw new Error(`git exact-commit ${operation} requires at least one --path`);
|
|
if (operation === "run" && message.length === 0) throw new Error("git exact-commit run requires --message");
|
|
if ((operation === "run" || operation === "gc-auto") && !confirm) throw new Error(`git exact-commit ${operation} requires --confirm`);
|
|
const cwd = powerShellSingleQuote(route.workspace ?? ".");
|
|
const pathList = paths.map((value) => powerShellSingleQuote(value)).join(",");
|
|
return `$ErrorActionPreference='Stop'
|
|
$ProgressPreference='SilentlyContinue'
|
|
[Console]::InputEncoding=[System.Text.UTF8Encoding]::new()
|
|
[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new()
|
|
$OutputEncoding=[System.Text.UTF8Encoding]::new()
|
|
Set-Location -LiteralPath ${cwd}
|
|
$operation=${powerShellSingleQuote(operation)}
|
|
$paths=@(${pathList})
|
|
$message=${powerShellSingleQuote(message)}
|
|
function Invoke-Git([string[]]$GitArgs) {
|
|
$output = @(& git @GitArgs 2>&1)
|
|
if ($LASTEXITCODE -ne 0) { throw (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine) }
|
|
return (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim()
|
|
}
|
|
function Object-Status {
|
|
$values=@{}
|
|
(Invoke-Git @('count-objects','-v')).Split([Environment]::NewLine) | ForEach-Object { $pair=$_.Split(':',2); if($pair.Count -eq 2){$values[$pair[0].Trim()]=[int64]$pair[1].Trim()} }
|
|
return $values
|
|
}
|
|
$before=Object-Status
|
|
$head=Invoke-Git @('rev-parse','--verify','HEAD^{commit}')
|
|
$branch=Invoke-Git @('symbolic-ref','-q','HEAD')
|
|
if($operation -eq 'status') {
|
|
[ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;objects=$before;safeStop='read-only; no objects were pruned'} | ConvertTo-Json -Depth 8 -Compress
|
|
exit 0
|
|
}
|
|
if($operation -eq 'gc-auto') {
|
|
$gcOutput=Invoke-Git @('gc','--auto')
|
|
$after=Object-Status
|
|
[ordered]@{ok=$true;operation=$operation;head=$head;before=$before;after=$after;gcOutput=$gcOutput;safeStop='git gc --auto only; Git recent-object and reflog retention remain authoritative; no direct prune'} | ConvertTo-Json -Depth 8 -Compress
|
|
exit 0
|
|
}
|
|
$gitDir=Invoke-Git @('rev-parse','--path-format=absolute','--git-dir')
|
|
$indexPath=Invoke-Git @('rev-parse','--path-format=absolute','--git-path','index')
|
|
$unrelatedPathspec=@('.')+@($paths | ForEach-Object { ':(exclude)'+$_ })
|
|
$unrelatedBefore=Invoke-Git (@('status','--porcelain=v2','--')+$unrelatedPathspec)
|
|
$tempIndex=Join-Path $gitDir ('unidesk-exact-index-'+[Guid]::NewGuid().ToString('N'))
|
|
try {
|
|
$env:GIT_INDEX_FILE=$tempIndex
|
|
[void](Invoke-Git @('read-tree',$head))
|
|
[void](Invoke-Git (@('add','--')+$paths))
|
|
$tree=Invoke-Git @('write-tree')
|
|
$parentTree=Invoke-Git @('rev-parse',($head+'^{tree}'))
|
|
if($tree -eq $parentTree){throw 'selected paths do not change the HEAD tree'}
|
|
if($operation -eq 'plan') {
|
|
[ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;tree=$tree;paths=$paths;tempIndex=$tempIndex;objectsBefore=$before;safeStop='plan only; ref and original index unchanged'} | ConvertTo-Json -Depth 8 -Compress
|
|
exit 0
|
|
}
|
|
$commit=($message | & git commit-tree $tree -p $head).Trim()
|
|
if($LASTEXITCODE -ne 0 -or !$commit){throw 'git commit-tree failed'}
|
|
[void](Invoke-Git @('update-ref','-m',('unidesk exact-commit: '+$message),$branch,$commit,$head))
|
|
Remove-Item Env:GIT_INDEX_FILE -ErrorAction SilentlyContinue
|
|
[void](Invoke-Git (@('reset',$commit,'--')+$paths))
|
|
$unrelatedAfter=Invoke-Git (@('status','--porcelain=v2','--')+$unrelatedPathspec)
|
|
if($unrelatedAfter -ne $unrelatedBefore){throw 'unselected worktree/index fingerprint changed after exact commit'}
|
|
$after=Object-Status
|
|
[ordered]@{ok=$true;operation=$operation;oldHead=$head;commit=$commit;tree=$tree;branch=$branch;paths=$paths;tempIndex=$tempIndex;unselectedStateUnchanged=$true;selectedPathsIndexUpdated=$true;objectsBefore=$before;objectsAfter=$after;safeStop='ref updated with old-value CAS; no prune performed'} | ConvertTo-Json -Depth 8 -Compress
|
|
} finally {
|
|
Remove-Item Env:GIT_INDEX_FILE -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $tempIndex -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath ($tempIndex+'.lock') -Force -ErrorAction SilentlyContinue
|
|
}`;
|
|
}
|
|
|
|
function validateWindowsGitCommitArgs(route: ParsedSshRoute, gitArgs: string[]): void {
|
|
const hasNonInteractiveMessage = gitArgs.slice(1).some((arg) => (
|
|
arg === "--dry-run" ||
|
|
arg === "--no-edit" ||
|
|
arg === "-m" ||
|
|
arg.startsWith("-m") ||
|
|
arg === "--message" ||
|
|
arg.startsWith("--message=") ||
|
|
arg === "-F" ||
|
|
arg.startsWith("-F") ||
|
|
arg === "--file" ||
|
|
arg.startsWith("--file=") ||
|
|
arg === "-C" ||
|
|
arg.startsWith("-C") ||
|
|
arg === "--reuse-message" ||
|
|
arg.startsWith("--reuse-message=") ||
|
|
arg.startsWith("--fixup=")
|
|
));
|
|
if (hasNonInteractiveMessage) return;
|
|
const entrypoint = sshDisplayEntrypoint();
|
|
throw new Error(`ssh win git commit would open an editor; use ${entrypoint} ${route.raw} git commit -m <message>, ${entrypoint} ${route.raw} git commit --no-edit, or wrap an interactive command with ${entrypoint} ${route.raw} ps <<'PS'`);
|
|
}
|
|
|
|
function buildWindowsCmdLine(userCommand: string, cwd: string | null): string {
|
|
const parts = [
|
|
"chcp 65001>nul",
|
|
'set "PYTHONUTF8=1"',
|
|
'set "PYTHONIOENCODING=utf-8"',
|
|
];
|
|
if (cwd !== null) parts.push(`cd /d ${windowsCmdQuote(cwd)}`);
|
|
parts.push(userCommand);
|
|
return parts.join(" && ");
|
|
}
|
|
|
|
function windowsCmdQuote(value: string): string {
|
|
if (/[\r\n"]/u.test(value)) throw new Error("ssh win workspace path must not contain quotes or newlines");
|
|
return `"${value}"`;
|
|
}
|
|
|
|
function windowsCmdArgument(value: string, route: ParsedSshRoute): string {
|
|
if (value.length === 0) return "\"\"";
|
|
if (/[\r\n"]/u.test(value) || /[&|<>^%!]/u.test(value)) {
|
|
const entrypoint = sshDisplayEntrypoint();
|
|
throw new Error(`ssh win git argument contains characters that require shell review; use ${entrypoint} ${route.raw} ps <<'PS' for this command`);
|
|
}
|
|
return /\s/u.test(value) ? `"${value}"` : value;
|
|
}
|
|
|
|
function parseWindowsSkillsOptions(args: string[]): { limit: number; scopes: Array<"agents" | "codex">; name: string | null; filter: string | null } {
|
|
let limit = 100;
|
|
let scope: "agents" | "codex" | "all" = "agents";
|
|
let name: string | null = null;
|
|
let filter: string | null = null;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === "--limit") {
|
|
const value = args[index + 1];
|
|
if (value === undefined) throw new Error("ssh win skills --limit requires a value");
|
|
limit = positiveInt(value, "ssh win skills --limit");
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--scope") {
|
|
const value = args[index + 1];
|
|
if (value === undefined) throw new Error("ssh win skills --scope requires a value");
|
|
if (value !== "agents" && value !== "codex" && value !== "all") throw new Error("ssh win skills --scope must be one of: agents, codex, all");
|
|
scope = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--all") {
|
|
scope = "all";
|
|
continue;
|
|
}
|
|
if (arg === "--name" || arg === "--filter") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error(`ssh win skills ${arg} requires a value`);
|
|
if (arg === "--name") name = value;
|
|
else filter = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
throw new Error(`unsupported ssh win skills option: ${arg}`);
|
|
}
|
|
if (name !== null && filter !== null) throw new Error("ssh win skills accepts only one of --name or --filter");
|
|
return { limit, scopes: scope === "all" ? ["agents", "codex"] : [scope], name, filter };
|
|
}
|
|
|
|
function buildWindowsSkillsDiscoveryScript(args: string[]): string {
|
|
const options = parseWindowsSkillsOptions(args);
|
|
const rootEntries = options.scopes.map((scope) => {
|
|
const relative = scope === "agents" ? ".agents\\skills" : ".codex\\skills";
|
|
return `@{ Scope = ${powerShellSingleQuote(scope)}; Path = (Join-Path $env:USERPROFILE ${powerShellSingleQuote(relative)}) }`;
|
|
}).join(", ");
|
|
return [
|
|
"$ErrorActionPreference = 'Stop';",
|
|
"$ProgressPreference = 'SilentlyContinue';",
|
|
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"$OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
`$limit = ${options.limit};`,
|
|
`$exactName = ${powerShellSingleQuote(options.name ?? "")};`,
|
|
`$filter = ${powerShellSingleQuote(options.filter ?? "")};`,
|
|
`$roots = @(${rootEntries});`,
|
|
"$rootRecords = @();",
|
|
"$skills = @();",
|
|
"foreach ($root in $roots) {",
|
|
" $exists = Test-Path -LiteralPath $root.Path -PathType Container;",
|
|
" $rootRecords += [pscustomobject]@{ scope = $root.Scope; path = $root.Path; exists = $exists };",
|
|
" if (-not $exists) { continue };",
|
|
" foreach ($dir in @(Get-ChildItem -LiteralPath $root.Path -Directory -Force | Sort-Object Name)) {",
|
|
" if ($skills.Count -ge $limit) { break };",
|
|
" $skillFile = Join-Path $dir.FullName 'SKILL.md';",
|
|
" $hasSkillMd = Test-Path -LiteralPath $skillFile -PathType Leaf;",
|
|
" if (-not $hasSkillMd) { continue };",
|
|
" $name = $dir.Name;",
|
|
" $description = '';",
|
|
" if ($hasSkillMd) {",
|
|
" foreach ($line in @(Get-Content -LiteralPath $skillFile -Encoding UTF8 -TotalCount 80)) {",
|
|
" if ($line -match '^name:\\s*(.+)$') { $name = $Matches[1].Trim(); continue };",
|
|
" if ($line -match '^description:\\s*(.+)$') { $description = $Matches[1].Trim(); continue };",
|
|
" }",
|
|
" }",
|
|
" if (-not [string]::IsNullOrWhiteSpace($exactName) -and $name -ine $exactName -and $dir.Name -ine $exactName) { continue };",
|
|
" if (-not [string]::IsNullOrWhiteSpace($filter) -and $name.IndexOf($filter, [StringComparison]::OrdinalIgnoreCase) -lt 0 -and $dir.Name.IndexOf($filter, [StringComparison]::OrdinalIgnoreCase) -lt 0 -and $description.IndexOf($filter, [StringComparison]::OrdinalIgnoreCase) -lt 0) { continue };",
|
|
" $skills += [pscustomobject]@{ scope = $root.Scope; name = $name; directoryName = $dir.Name; path = $dir.FullName; skillFile = $skillFile; hasSkillMd = $hasSkillMd; description = $description };",
|
|
" }",
|
|
"}",
|
|
"$payload = [pscustomobject]@{ ok = $true; command = 'unidesk ssh win skills'; generatedAt = (Get-Date).ToUniversalTime().ToString('o'); user = $env:USERNAME; userProfile = $env:USERPROFILE; query = [pscustomobject]@{ name = $exactName; filter = $filter }; counts = [pscustomobject]@{ roots = $rootRecords.Count; skills = $skills.Count; limit = $limit }; roots = $rootRecords; skills = @($skills) };",
|
|
"$payload | ConvertTo-Json -Depth 6;",
|
|
].join(" ");
|
|
}
|
|
|
|
export function powerShellSingleQuote(value: string): string {
|
|
return `'${value.replace(/'/g, "''")}'`;
|
|
}
|
|
|
|
function buildWindowsCmdLauncherScript(cmdLine: string): string {
|
|
return [
|
|
"$ErrorActionPreference = 'Stop';",
|
|
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"$OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"$env:PYTHONUTF8 = '1';",
|
|
"$env:PYTHONIOENCODING = 'utf-8';",
|
|
`& ${powerShellSingleQuote(windowsCmdExeNativePath)} /d /s /c ${powerShellSingleQuote(cmdLine)};`,
|
|
"exit $LASTEXITCODE;",
|
|
].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();",
|
|
"$script = $script -replace \"`r`n\", \"`n\";",
|
|
"$script = $script -replace \"`r\", \"`n\";",
|
|
"$script = $script -replace \"`n\", \"`r`n\";",
|
|
"if (-not $script.EndsWith(\"`r`n\")) { $script += \"`r`n\" }",
|
|
"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(" ");
|
|
}
|
|
|
|
const windowsPowerShellJsonSafetyPreludeLines = [
|
|
"function ConvertTo-UniDeskPlainJsonValue {",
|
|
" param([object]$Value, [int]$Depth = 6, [int]$Level = 0)",
|
|
" if ($null -eq $Value) { return $null }",
|
|
" if ($Value -is [string]) { return [string]$Value }",
|
|
" if ($Value -is [char]) { return [string]$Value }",
|
|
" if ($Value -is [bool] -or $Value -is [byte] -or $Value -is [sbyte] -or $Value -is [int16] -or $Value -is [uint16] -or $Value -is [int] -or $Value -is [uint32] -or $Value -is [long] -or $Value -is [uint64] -or $Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { return $Value }",
|
|
" if ($Value -is [datetime]) { return $Value.ToUniversalTime().ToString('o') }",
|
|
" if ($Level -ge $Depth) { return [string]$Value }",
|
|
" if ($Value -is [System.Collections.IDictionary]) {",
|
|
" $map = [ordered]@{}",
|
|
" foreach ($key in $Value.Keys) { $map[[string]$key] = ConvertTo-UniDeskPlainJsonValue -Value $Value[$key] -Depth $Depth -Level ($Level + 1) }",
|
|
" return $map",
|
|
" }",
|
|
" if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {",
|
|
" $items = New-Object System.Collections.ArrayList",
|
|
" foreach ($item in $Value) { [void]$items.Add((ConvertTo-UniDeskPlainJsonValue -Value $item -Depth $Depth -Level ($Level + 1))) }",
|
|
" return $items.ToArray()",
|
|
" }",
|
|
" $skip = @('PSPath','PSParentPath','PSChildName','PSDrive','PSProvider','ReadCount')",
|
|
" $props = @($Value.PSObject.Properties | Where-Object { @('NoteProperty','Property','AliasProperty') -contains $_.MemberType.ToString() -and -not ($skip -contains $_.Name) })",
|
|
" if ($props.Count -eq 0) { return [string]$Value }",
|
|
" $out = [ordered]@{}",
|
|
" foreach ($prop in $props) {",
|
|
" try { $out[$prop.Name] = ConvertTo-UniDeskPlainJsonValue -Value $prop.Value -Depth $Depth -Level ($Level + 1) }",
|
|
" catch { $out[$prop.Name] = '<unreadable>' }",
|
|
" }",
|
|
" return $out",
|
|
"}",
|
|
"function ConvertTo-Json {",
|
|
" [CmdletBinding()]",
|
|
" param(",
|
|
" [Parameter(ValueFromPipeline=$true)] [object]$InputObject,",
|
|
" [int]$Depth = 2,",
|
|
" [switch]$Compress,",
|
|
" [switch]$EnumsAsStrings",
|
|
" )",
|
|
" begin { $items = New-Object System.Collections.ArrayList }",
|
|
" process { [void]$items.Add($InputObject) }",
|
|
" end {",
|
|
" if ($items.Count -eq 0) { $value = $null } elseif ($items.Count -eq 1) { $value = $items[0] } else { $value = $items.ToArray() }",
|
|
" $plain = ConvertTo-UniDeskPlainJsonValue -Value $value -Depth $Depth -Level 0",
|
|
" if ($Compress) {",
|
|
" Microsoft.PowerShell.Utility\\ConvertTo-Json -InputObject $plain -Depth $Depth -Compress",
|
|
" } else {",
|
|
" Microsoft.PowerShell.Utility\\ConvertTo-Json -InputObject $plain -Depth $Depth",
|
|
" }",
|
|
" }",
|
|
"}",
|
|
];
|
|
|
|
export function windowsPowerShellScriptPrelude(cwd: string | null): string {
|
|
return [
|
|
"$ErrorActionPreference = 'Stop'",
|
|
"$ProgressPreference = 'SilentlyContinue'",
|
|
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new()",
|
|
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()",
|
|
"$OutputEncoding = [System.Text.UTF8Encoding]::new()",
|
|
"$PSDefaultParameterValues['Get-Content:Encoding'] = 'utf8'",
|
|
"$env:PYTHONUTF8 = '1'",
|
|
"$env:PYTHONIOENCODING = 'utf-8'",
|
|
...windowsPowerShellJsonSafetyPreludeLines,
|
|
...(cwd === null ? [] : [`Set-Location -LiteralPath ${powerShellSingleQuote(cwd)}`]),
|
|
].join("\r\n") + "\r\n";
|
|
}
|
|
|
|
function buildWindowsPowerShellInlineLauncherScript(command: string, cwd: string | null): string {
|
|
if (command.trim().length === 0) throw new Error("ssh win ps requires a command or stdin PowerShell script");
|
|
return buildWindowsPowerShellScriptRunner(powerShellSingleQuote(command), cwd);
|
|
}
|
|
|
|
function buildWindowsPowerShellArgvLauncherScript(commandArgs: string[], cwd: string | null): string {
|
|
const command = commandArgs[0] ?? "";
|
|
if (command.trim().length === 0) throw new Error("ssh win ps requires a command before argv arguments");
|
|
const args = commandArgs.slice(1);
|
|
const payloadJsonB64 = Buffer.from(JSON.stringify({
|
|
command,
|
|
args,
|
|
nativeArguments: args.map(windowsNativeProcessArgument).join(" "),
|
|
}), "utf8").toString("base64");
|
|
const script = [
|
|
`$payloadJsonB64 = ${powerShellSingleQuote(payloadJsonB64)};`,
|
|
"$payload = ConvertFrom-Json -InputObject ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payloadJsonB64)));",
|
|
"$command = [string]$payload.command;",
|
|
"$commandArgs = @();",
|
|
"if ($null -ne $payload.args) { foreach ($item in @($payload.args)) { $commandArgs += [string]$item } }",
|
|
"$resolvedCommand = Get-Command -Name $command -ErrorAction Stop;",
|
|
"if ($resolvedCommand.CommandType -eq 'Application') {",
|
|
" $startInfo = [Diagnostics.ProcessStartInfo]::new();",
|
|
" $startInfo.FileName = $resolvedCommand.Source;",
|
|
" $startInfo.Arguments = [string]$payload.nativeArguments;",
|
|
" $startInfo.WorkingDirectory = (Get-Location).ProviderPath;",
|
|
" $startInfo.UseShellExecute = $false;",
|
|
" $process = [Diagnostics.Process]::new(); $process.StartInfo = $startInfo;",
|
|
" if (-not $process.Start()) { throw ('failed to start native command: ' + $command) }",
|
|
" $process.WaitForExit(); $global:LASTEXITCODE = $process.ExitCode;",
|
|
"} else { & $command @commandArgs; }",
|
|
].join(" ");
|
|
return buildWindowsPowerShellScriptRunner(powerShellSingleQuote(script), cwd);
|
|
}
|
|
|
|
function windowsNativeProcessArgument(value: string): string {
|
|
if (value.length === 0) return '""';
|
|
let quoted = '"';
|
|
let backslashes = 0;
|
|
for (const character of value) {
|
|
if (character === "\\") {
|
|
backslashes += 1;
|
|
continue;
|
|
}
|
|
if (character === '"') {
|
|
quoted += "\\".repeat(backslashes * 2 + 1) + '"';
|
|
backslashes = 0;
|
|
continue;
|
|
}
|
|
quoted += "\\".repeat(backslashes) + character;
|
|
backslashes = 0;
|
|
}
|
|
return quoted + "\\".repeat(backslashes * 2) + '"';
|
|
}
|
|
|
|
function buildWindowsPowerShellStdinLauncherScript(cwd: string | null): string {
|
|
return buildWindowsPowerShellScriptRunner("[Console]::In.ReadToEnd()", cwd);
|
|
}
|
|
|
|
function buildWindowsPowerShellScriptRunner(scriptExpression: string, cwd: string | null): string {
|
|
return [
|
|
"$ErrorActionPreference = 'Stop';",
|
|
"$ProgressPreference = 'SilentlyContinue';",
|
|
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"$OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
`$script = ${scriptExpression};`,
|
|
"$script = $script -replace \"`r`n\", \"`n\";",
|
|
"$script = $script -replace \"`r\", \"`n\";",
|
|
"$script = $script -replace \"`n\", \"`r`n\";",
|
|
"if (-not $script.EndsWith(\"`r`n\")) { $script += \"`r`n\" }",
|
|
"if ([string]::IsNullOrWhiteSpace($script)) { [Console]::Error.WriteLine('ssh win ps requires a command or stdin PowerShell script'); exit 2 }",
|
|
"$script += \"`r`nif (`$global:LASTEXITCODE -is [int] -and `$global:LASTEXITCODE -ne 0) { exit `$global:LASTEXITCODE }`r`n\";",
|
|
`$prefix = ${powerShellSingleQuote(windowsPowerShellScriptPrelude(cwd))};`,
|
|
"$temp = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), ('unidesk-win-ps-' + [guid]::NewGuid().ToString('N') + '.ps1'));",
|
|
"try {",
|
|
" [System.IO.File]::WriteAllText($temp, $prefix + $script, [System.Text.UTF8Encoding]::new($true));",
|
|
" & (Join-Path $PSHOME 'powershell.exe') -NoProfile -ExecutionPolicy Bypass -File $temp;",
|
|
" $code = $LASTEXITCODE;",
|
|
" if ($null -eq $code) { $code = 0 }",
|
|
"} finally {",
|
|
" Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue;",
|
|
"}",
|
|
"exit $code;",
|
|
].join(" ");
|
|
}
|
|
|
|
export function buildWindowsPowerShellInvocation(script: string): string {
|
|
return shellArgv([
|
|
windowsPowerShellExePath,
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-EncodedCommand",
|
|
Buffer.from(script, "utf16le").toString("base64"),
|
|
]);
|
|
}
|
|
|
|
export function sshRoutePayloadCwd(route: ParsedSshRoute): string | undefined {
|
|
if (route.plane === "host") return route.workspace ?? undefined;
|
|
if (route.plane === "win") return windowsBridgeCwd;
|
|
return undefined;
|
|
}
|
|
|
|
function routeSegmentHead(segment: string): string {
|
|
return segment.split("/")[0] ?? segment;
|
|
}
|
|
|
|
function parseK3sRouteTargetSegments(rawResource: string | null, rawContainer: string | null): { resource: string | null; container: string | null; workspace: string | null } {
|
|
const resourceParts = splitK3sResourceWorkspace(rawResource);
|
|
const containerParts = splitK3sContainerWorkspace(rawContainer);
|
|
return {
|
|
resource: resourceParts.resource,
|
|
container: containerParts.container,
|
|
workspace: combineK3sRouteWorkspace(resourceParts.workspace, containerParts.workspace),
|
|
};
|
|
}
|
|
|
|
function normalizeK3sRouteRestSegments(rest: string[]): string[] {
|
|
const normalized: string[] = [];
|
|
for (let index = 0; index < rest.length; index += 1) {
|
|
const current = rest[index] ?? "";
|
|
const kindRoute = k3sColonKindRoute(current);
|
|
if (kindRoute === null) {
|
|
normalized.push(current);
|
|
continue;
|
|
}
|
|
if (kindRoute.name === null) {
|
|
const next = rest[index + 1];
|
|
if (next === undefined || next.length === 0) throw new Error(`ssh k3s ${kindRoute.kind}: route requires a resource name after ${kindRoute.kind}:`);
|
|
normalized.push(`${kindRoute.kind}/${next}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
normalized.push(`${kindRoute.kind}/${kindRoute.name}`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function rejectK3sSlashKindRouteSegments(target: string, rest: string[]): void {
|
|
const resourceSegment = rest[1];
|
|
if (resourceSegment === undefined || resourceSegment.length === 0) return;
|
|
const slashIndex = resourceSegment.indexOf("/");
|
|
if (slashIndex < 0) return;
|
|
const head = resourceSegment.slice(0, slashIndex);
|
|
if (!isK3sResourceKindAlias(head)) return;
|
|
throw new Error(
|
|
`ssh k3s route segment "${resourceSegment}" in "${target}" uses "/" between Kubernetes route parts. ` +
|
|
`":" is the distributed route separator and "/" is only for the in-container filesystem cwd. ` +
|
|
`Use "trans <provider>:k3s:<namespace>:${head}:<name>[:<container>] ..." for ${head} routes, ` +
|
|
`and use "--cwd /path" or a route cwd suffix only for filesystem paths.`
|
|
);
|
|
}
|
|
|
|
function k3sColonKindRoute(value: string): { kind: string; name: string | null } | null {
|
|
const colonIndex = value.indexOf(":");
|
|
const head = colonIndex < 0 ? value : value.slice(0, colonIndex);
|
|
if (!isK3sResourceKindAlias(head)) return null;
|
|
if (colonIndex < 0) return { kind: head, name: null };
|
|
const name = value.slice(colonIndex + 1);
|
|
if (name.length === 0) throw new Error(`ssh k3s ${head}: route requires a resource name after ${head}:`);
|
|
return { kind: head, name };
|
|
}
|
|
|
|
function splitK3sResourceWorkspace(value: string | null): { resource: string | null; workspace: string | null } {
|
|
if (value === null || value.length === 0) return { resource: null, workspace: null };
|
|
const parts = value.split("/");
|
|
if (parts.length <= 1) return { resource: value, workspace: null };
|
|
if (isK3sResourceKindAlias(parts[0] ?? "") && (parts[1] ?? "").length > 0) {
|
|
const workspaceParts = parts.slice(2);
|
|
return {
|
|
resource: `${parts[0]}/${parts[1]}`,
|
|
workspace: workspaceParts.length > 0 ? `/${workspaceParts.join("/")}` : null,
|
|
};
|
|
}
|
|
return {
|
|
resource: parts[0] ?? value,
|
|
workspace: parts.length > 1 ? `/${parts.slice(1).join("/")}` : null,
|
|
};
|
|
}
|
|
|
|
function splitK3sContainerWorkspace(value: string | null): { container: string | null; workspace: string | null } {
|
|
if (value === null || value.length === 0) return { container: null, workspace: null };
|
|
const parts = value.split("/");
|
|
if (parts.length <= 1) return { container: value, workspace: null };
|
|
return { container: parts[0] ?? value, workspace: `/${parts.slice(1).join("/")}` };
|
|
}
|
|
|
|
function combineK3sRouteWorkspace(first: string | null, second: string | null): string | null {
|
|
if (first !== null && second !== null && first !== second) throw new Error("ssh k3s route workspace can be specified once, either after the workload or after the container");
|
|
return first ?? second;
|
|
}
|
|
|
|
function isK3sResourceKindAlias(value: string): boolean {
|
|
return k3sResourceKindAliases.has(value);
|
|
}
|
|
|
|
function k3sOperationInRouteMessage(target: string, operation: string): string {
|
|
const providerId = target.split(":")[0] || "<provider>";
|
|
if (operation === "v2" || operation === "patch" || operation === "patch-v1") {
|
|
return `ssh k3s route must locate a target only; remote patch entrypoints are "apply-patch" for the default v2 engine and "apply-patch-v1" for the legacy helper instead of "${target}"`;
|
|
}
|
|
if (operation === "script" || operation === "shell") {
|
|
return `ssh k3s route must locate a target only; operation "${operation}" has been removed because it hides the shell dialect; use explicit "sh" or "bash" after the route instead of "${target}"`;
|
|
}
|
|
const operationExample = operation === "guard" ? "guard" : `${operation} ...`;
|
|
return `ssh k3s route must locate a target only; put operation "${operation}" after the route, for example "ssh ${providerId}:k3s ${operationExample}" or "ssh ${providerId}:k3s:<namespace>:<workload> ${operationExample}" instead of "${target}"`;
|
|
}
|
|
|
|
export function shellArgv(args: string[]): string {
|
|
return args.map(shellQuote).join(" ");
|
|
}
|
|
|
|
export function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
function positiveInt(value: string, option: string): number {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`);
|
|
return parsed;
|
|
}
|
|
|
|
function optionalPositiveInt(value: string, option: string): string {
|
|
return String(positiveInt(value, option));
|
|
}
|
|
|
|
function findOptionValue(args: string[], index: number, option: string): string {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error(`ssh find ${option} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function buildFindCommand(args: string[]): string {
|
|
const paths: string[] = [];
|
|
const predicates: string[] = [];
|
|
const patternPredicates: Array<[string, string]> = [];
|
|
let limit: number | null = null;
|
|
let sortOutput = false;
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === "--limit") {
|
|
const value = findOptionValue(args, index, arg);
|
|
limit = positiveInt(value, "ssh find --limit");
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--sort") {
|
|
sortOutput = true;
|
|
continue;
|
|
}
|
|
if (arg === "--max-depth" || arg === "-maxdepth" || arg === "--min-depth" || arg === "-mindepth") {
|
|
const value = findOptionValue(args, index, arg);
|
|
const findArg = arg === "--max-depth" ? "-maxdepth" : arg === "--min-depth" ? "-mindepth" : arg;
|
|
predicates.push(findArg, String(positiveInt(value, `ssh find ${arg}`)));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--type" || arg === "-type") {
|
|
const value = findOptionValue(args, index, arg);
|
|
if (!/^[bcdpfls]$/u.test(value)) throw new Error("ssh find --type must be one of: b c d p f l s");
|
|
predicates.push("-type", value);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--name" || arg === "-name" || arg === "--iname" || arg === "-iname" || arg === "--path" || arg === "-path" || arg === "--ipath" || arg === "-ipath") {
|
|
const value = findOptionValue(args, index, arg);
|
|
const findArg = arg.startsWith("--") ? `-${arg.slice(2)}` : arg;
|
|
patternPredicates.push([findArg, value]);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--contains" || arg === "--icontains") {
|
|
const value = findOptionValue(args, index, arg);
|
|
const findArg = arg === "--contains" ? "-name" : "-iname";
|
|
patternPredicates.push([findArg, `*${value}*`]);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--mtime" || arg === "-mtime" || arg === "--mmin" || arg === "-mmin" || arg === "--size" || arg === "-size") {
|
|
const value = findOptionValue(args, index, arg);
|
|
const findArg = arg.startsWith("--") ? `-${arg.slice(2)}` : arg;
|
|
predicates.push(findArg, value);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-")) {
|
|
throw new Error(`unsupported ssh find option: ${arg}`);
|
|
}
|
|
paths.push(arg);
|
|
}
|
|
|
|
const findArgs = ["find", ...(paths.length > 0 ? paths : ["."]), ...predicates];
|
|
if (patternPredicates.length === 1) {
|
|
const [kind, pattern] = patternPredicates[0]!;
|
|
findArgs.push(kind, pattern);
|
|
} else if (patternPredicates.length > 1) {
|
|
findArgs.push("(");
|
|
patternPredicates.forEach(([kind, pattern], index) => {
|
|
if (index > 0) findArgs.push("-o");
|
|
findArgs.push(kind, pattern);
|
|
});
|
|
findArgs.push(")");
|
|
}
|
|
findArgs.push("-print");
|
|
|
|
let command = shellArgv(findArgs);
|
|
if (sortOutput) command = `${command} | sort`;
|
|
if (limit !== null) command = `${command} | head -n ${limit}`;
|
|
return command;
|
|
}
|
|
|
|
function parseK3sRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
|
|
if (route.entry === null && route.namespace === null && route.resource === null) {
|
|
return parseK3sControlPlaneOperation(route, args);
|
|
}
|
|
if (route.namespace === null || route.resource === null) {
|
|
throw new Error("ssh k3s target route requires provider:k3s:<namespace>:<workload|pod:pod-name>[:<container>]; use --cwd /path for an in-container filesystem cwd");
|
|
}
|
|
return parseK3sTargetOperation(route, args);
|
|
}
|
|
|
|
function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
|
|
const operation = args[0] ?? "guard";
|
|
if (operation === "upload" || operation === "download") {
|
|
throw new Error(`ssh ${route.providerId}:k3s ${operation} requires a workload route: ssh ${route.providerId}:k3s:<namespace>:<workload> ${operation} ...`);
|
|
}
|
|
if (operation === "apply-patch" || operation === "apply-patch-v1") {
|
|
throw new Error(`ssh ${route.providerId}:k3s apply-patch requires a workload route: ssh ${route.providerId}:k3s:<namespace>:<workload> apply-patch`);
|
|
}
|
|
if (operation === "patch" || operation === "patch-v1" || operation === "v2") {
|
|
throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper");
|
|
}
|
|
if (operation === "script" || operation === "shell") {
|
|
throw removedShellAliasError(operation, route.raw, args.slice(1));
|
|
}
|
|
if (operation === "sh" || operation === "bash") {
|
|
return buildK3sScriptOperation(args.slice(1), operation, `ssh ${route.providerId}:k3s ${operation}`);
|
|
}
|
|
if (operation === "guard") {
|
|
if (args.length > 1) throw new Error(`ssh ${route.providerId}:k3s guard does not accept extra arguments`);
|
|
return { remoteCommand: buildK3sGuardCommand(route.providerId), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
return { remoteCommand: buildK3sCommand(route.providerId, route.raw, args), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
|
|
function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
|
|
const targetArgs = k3sRouteTargetArgs(route);
|
|
if (args.length === 0) {
|
|
return {
|
|
remoteCommand: buildK3sTargetObjectCommand("get", route, ["-o", "wide"]),
|
|
requiresStdin: false,
|
|
invocationKind: "helper",
|
|
};
|
|
}
|
|
const operation = args[0] ?? "";
|
|
const operationArgs = args.slice(1);
|
|
if (operation === "upload" || operation === "download") {
|
|
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
if (operation === "apply-patch") {
|
|
if (isApplyPatchV2HelpArgs(operationArgs)) {
|
|
return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" };
|
|
}
|
|
if (operation === "apply-patch-v1") return buildK3sApplyPatchCommand([...targetArgs, ...operationArgs]);
|
|
if (operation === "patch" || operation === "patch-v1" || operation === "v2") {
|
|
throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper");
|
|
}
|
|
if (operation === "script" || operation === "shell") {
|
|
throw removedShellAliasError(operation, route.raw, operationArgs);
|
|
}
|
|
if (operation === "sh" || operation === "bash") {
|
|
return buildK3sScriptOperation([...targetArgs, ...operationArgs], operation, `ssh ${route.raw} ${operation}`);
|
|
}
|
|
if (operation === "logs") return { remoteCommand: buildK3sLogsCommand([...targetArgs, ...operationArgs]), requiresStdin: false, invocationKind: "helper" };
|
|
if (operation === "argv") {
|
|
validateDirectArgvCommand(operation, operationArgs);
|
|
return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(operationArgs)]), requiresStdin: false, invocationKind: "argv" };
|
|
}
|
|
if (operation === "get" || operation === "describe") {
|
|
return { remoteCommand: buildK3sTargetObjectCommand(operation, route, operationArgs), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
if (operation === "kubectl") throw new Error(`ssh k3s kubectl is a control-plane operation; use ssh ${route.providerId}:k3s kubectl ...`);
|
|
if (operation === "exec") {
|
|
const execArgs = k3sRouteExecOperationArgs(operationArgs);
|
|
return {
|
|
remoteCommand: buildK3sExecCommand([...targetArgs, ...execArgs]),
|
|
requiresStdin: execArgs.includes("--stdin") || execArgs.includes("-i"),
|
|
invocationKind: "helper",
|
|
};
|
|
}
|
|
return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
|
|
function buildK3sTargetObjectCommand(action: "get" | "describe", route: ParsedSshRoute, args: string[]): string {
|
|
if (route.namespace === null || route.resource === null) throw new Error(`ssh k3s ${action} target requires namespace and workload route`);
|
|
if (args.includes("--follow") || args.includes("-f")) throw new Error(`ssh k3s target ${action} does not support follow mode`);
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", action, "-n", route.namespace, normalizeK3sRouteResource(route.resource), ...args]);
|
|
}
|
|
|
|
export function buildK3sTargetCommand(route: ParsedSshRoute, command: string[], options: { stdin?: boolean } = {}): string {
|
|
return buildK3sExecCommand([...k3sRouteTargetArgs(route), ...(options.stdin === true ? ["--stdin"] : []), "--", ...command]);
|
|
}
|
|
|
|
function k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
|
|
if (route.namespace === null) throw new Error(`ssh route ${route.raw} requires a namespace segment`);
|
|
if (route.resource === null) throw new Error(`ssh route ${route.raw} requires a workload or pod segment`);
|
|
return [
|
|
"--namespace", route.namespace,
|
|
"--resource", normalizeK3sRouteResource(route.resource),
|
|
...(route.container === null ? [] : ["--container", route.container]),
|
|
...(route.workspace === null ? [] : ["--workdir", route.workspace]),
|
|
];
|
|
}
|
|
|
|
function k3sRouteCommandArgs(args: string[]): string[] {
|
|
if (args.length === 0) throw new Error("ssh k3s target route requires a command to exec");
|
|
return args[0] === "--" ? args : ["--", ...args];
|
|
}
|
|
|
|
function k3sRouteExecOperationArgs(args: string[]): string[] {
|
|
if (args.length === 0) throw new Error("ssh k3s target exec operation requires a command to exec");
|
|
const result: string[] = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === "--") {
|
|
if (index === args.length - 1) throw new Error("ssh k3s target exec operation requires a command after --");
|
|
return [...result, ...args.slice(index)];
|
|
}
|
|
if (arg === "--stdin" || arg === "-i" || arg === "--tty" || arg === "-t") {
|
|
result.push(arg);
|
|
continue;
|
|
}
|
|
if (arg === "--container" || arg === "-c" || arg === "--workdir" || arg === "--cwd") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error(`ssh k3s target exec ${arg} requires a value`);
|
|
result.push(arg, value);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
return [...result, "--", ...args.slice(index)];
|
|
}
|
|
throw new Error("ssh k3s target exec operation requires a command to exec");
|
|
}
|
|
|
|
export function effectiveApplyPatchV2Invocation(invocation: ParsedSshInvocation, argv: string[]): EffectiveApplyPatchV2Invocation {
|
|
if (invocation.route.plane !== "k3s" || isApplyPatchV2HelpArgs(argv)) return { invocation, argv };
|
|
let container: string | null = null;
|
|
let workspace: string | null = null;
|
|
const remaining: string[] = [];
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index] ?? "";
|
|
if (arg === "--container" || arg === "-c") {
|
|
container = k3sOptionValue(argv, index, `ssh ${invocation.route.raw} apply-patch ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const containerValue = k3sEqualsOptionValue(arg, "--container", `ssh ${invocation.route.raw} apply-patch`);
|
|
if (containerValue !== null) {
|
|
container = containerValue;
|
|
continue;
|
|
}
|
|
if (arg === "--workdir" || arg === "--cwd") {
|
|
workspace = k3sWorkspaceValue(k3sOptionValue(argv, index, `ssh ${invocation.route.raw} apply-patch ${arg}`), `ssh ${invocation.route.raw} apply-patch ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const workdirValue = k3sEqualsOptionValue(arg, "--workdir", `ssh ${invocation.route.raw} apply-patch`) ?? k3sEqualsOptionValue(arg, "--cwd", `ssh ${invocation.route.raw} apply-patch`);
|
|
if (workdirValue !== null) {
|
|
workspace = k3sWorkspaceValue(workdirValue, `ssh ${invocation.route.raw} apply-patch ${arg.split("=", 1)[0]}`);
|
|
continue;
|
|
}
|
|
remaining.push(arg);
|
|
}
|
|
|
|
const route = invocation.route;
|
|
if (container !== null && route.container !== null && container !== route.container) {
|
|
throw new Error("ssh k3s apply-patch container can be specified once, either in the route :<container> segment or with --container");
|
|
}
|
|
|
|
return {
|
|
invocation: {
|
|
...invocation,
|
|
route: {
|
|
...route,
|
|
container: route.container ?? container,
|
|
workspace: combineK3sRouteWorkspace(route.workspace, workspace),
|
|
},
|
|
},
|
|
argv: remaining,
|
|
};
|
|
}
|
|
|
|
function buildK3sCommand(providerId: string, routeRaw: string, args: string[]): string {
|
|
const action = args[0] ?? "";
|
|
if (action.length === 0 || action === "--help" || action === "-h" || action === "help") {
|
|
throw new Error("ssh k3s requires a subcommand: guard, kubectl, get, describe, logs or exec");
|
|
}
|
|
if (action === "guard") return buildK3sGuardCommand(providerId);
|
|
if (action === "exec") return buildK3sExecCommand(args.slice(1));
|
|
if (action === "script" || action === "shell") {
|
|
throw removedShellAliasError(action, routeRaw, args.slice(1));
|
|
}
|
|
if (action === "sh" || action === "bash") {
|
|
const parsed = buildK3sScriptOperation(args.slice(1), action, `ssh k3s ${action}`);
|
|
if (parsed.remoteCommand === null) throw new Error(`ssh k3s ${action} resolved to an interactive command unexpectedly`);
|
|
return parsed.remoteCommand;
|
|
}
|
|
if (action === "logs") return buildK3sLogsCommand(args.slice(1));
|
|
if (action === "kubectl") {
|
|
const kubectlArgs = args.slice(1);
|
|
if (kubectlArgs.length === 0) throw new Error("ssh k3s kubectl requires kubectl arguments");
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
|
}
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...args]);
|
|
}
|
|
|
|
function buildK3sGuardCommand(providerId: string): string {
|
|
const provider = providerId.toUpperCase();
|
|
const providerNodeCheck = provider === "D601"
|
|
? "printf '%s\\n' \"$nodes\" | grep -Fx d601 >/dev/null || { printf 'native_k3s_guard=blocked provider=%s reason=d601-node-missing\\n' \"$UNIDESK_K3S_PROVIDER_ID\" >&2; exit 1; }"
|
|
: provider === "G14"
|
|
? "printf '%s\\n' \"$nodes\" | grep -Fx ubuntu-rog-zephyrus-g14-ga401iv-ga401iv >/dev/null || { printf 'native_k3s_guard=blocked provider=%s reason=g14-node-missing\\n' \"$UNIDESK_K3S_PROVIDER_ID\" >&2; exit 1; }"
|
|
: "[ -n \"$nodes\" ] || { printf 'native_k3s_guard=blocked provider=%s reason=node-list-empty\\n' \"$UNIDESK_K3S_PROVIDER_ID\" >&2; exit 1; }";
|
|
const script = [
|
|
"set -euo pipefail",
|
|
`export KUBECONFIG=${shellQuote(nativeK3sKubeconfig)}`,
|
|
`export UNIDESK_K3S_PROVIDER_ID=${shellQuote(providerId)}`,
|
|
"context=$(kubectl config current-context)",
|
|
"server=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')",
|
|
"nodes=$(kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{\"\\n\"}{end}')",
|
|
"printf 'kubeconfig=%s\\n' \"$KUBECONFIG\"",
|
|
"printf 'provider=%s\\n' \"$UNIDESK_K3S_PROVIDER_ID\"",
|
|
"printf 'context=%s\\n' \"$context\"",
|
|
"printf 'server=%s\\n' \"$server\"",
|
|
"printf 'nodes=%s\\n' \"$(printf '%s' \"$nodes\" | tr '\\n' ' ')\"",
|
|
providerNodeCheck,
|
|
"case \"$server\" in *127.0.0.1:11700*|*docker-desktop*) printf 'native_k3s_guard=blocked reason=docker-desktop-context server=%s\\n' \"$server\" >&2; exit 1;; esac",
|
|
"printf 'native_k3s_guard=ok provider=%s\\n' \"$UNIDESK_K3S_PROVIDER_ID\"",
|
|
].join("; ");
|
|
return shellArgv(["bash", "-c", script]);
|
|
}
|
|
|
|
interface K3sTargetOptions {
|
|
namespace: string | null;
|
|
resource: string | null;
|
|
selector: string | null;
|
|
container: string | null;
|
|
workspace: string | null;
|
|
stdin: boolean;
|
|
tty: boolean;
|
|
shell: string | null;
|
|
command: string[];
|
|
kubectlOptions: string[];
|
|
}
|
|
|
|
interface ParseK3sTargetOptionsOptions {
|
|
requireCommand: boolean;
|
|
allowCommand?: boolean;
|
|
allowShell?: boolean;
|
|
allowSelector?: boolean;
|
|
}
|
|
|
|
function buildK3sExecCommand(args: string[]): string {
|
|
const parsed = parseK3sTargetOptions(args, "ssh k3s exec", { requireCommand: true });
|
|
if (parsed.namespace === null) throw new Error("ssh k3s exec requires --namespace <name>");
|
|
if (parsed.selector !== null) throw new Error("ssh k3s exec does not support --selector");
|
|
if (parsed.resource === null) throw new Error("ssh k3s exec requires --deployment <name>, --pod <name> or --resource <type/name>");
|
|
const kubectlArgs = [
|
|
"exec",
|
|
"-n", parsed.namespace,
|
|
parsed.resource,
|
|
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
|
...(parsed.stdin ? ["-i"] : []),
|
|
...(parsed.tty ? ["-t"] : []),
|
|
...parsed.kubectlOptions,
|
|
"--",
|
|
...withK3sWorkspace(parsed, parsed.command),
|
|
];
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
|
}
|
|
|
|
function removedShellAliasError(operation: string, routeRaw: string, operationArgs: string[]): Error {
|
|
return new SshRemovedShellAliasError(operation, routeRaw, operationArgs);
|
|
}
|
|
|
|
function sshDisplayEntrypoint(): string {
|
|
const raw = process.env.UNIDESK_SSH_ENTRYPOINT?.trim();
|
|
return raw === "trans" || raw === "tran" || raw === "ssh" ? raw : "ssh";
|
|
}
|
|
|
|
function removedShellAliasReplacementExamples(entrypoint: string, route: string, operationArgs: string[]): RemovedShellAliasReplacementExamples {
|
|
const command = removedShellAliasInlineCommand(operationArgs);
|
|
const commandText = command === null ? "<command>" : command;
|
|
return {
|
|
posixSh: `${entrypoint} ${route} sh -- ${shellQuote(commandText)}`,
|
|
bash: `${entrypoint} ${route} bash -- ${shellQuote(commandText)}`,
|
|
templatePosixSh: `${entrypoint} ${route} sh -- '<command>'`,
|
|
templateBash: `${entrypoint} ${route} bash -- '<command>'`,
|
|
};
|
|
}
|
|
|
|
function removedShellAliasInlineCommand(operationArgs: string[]): string | null {
|
|
if (operationArgs.length === 0) return null;
|
|
const separatorIndex = operationArgs.indexOf("--");
|
|
if (separatorIndex >= 0) {
|
|
const command = operationArgs.slice(separatorIndex + 1);
|
|
return command.length === 1 ? command[0] ?? null : null;
|
|
}
|
|
return operationArgs.length === 1 && !(operationArgs[0] ?? "").startsWith("-") ? operationArgs[0] ?? null : null;
|
|
}
|
|
|
|
function buildK3sScriptOperation(args: string[], shell: "sh" | "bash", commandName: string): ParsedSshArgs {
|
|
const parsed = parseK3sTargetOptions(args, commandName, { requireCommand: false, allowCommand: true });
|
|
if (parsed.command.length > 0) {
|
|
return { remoteCommand: buildK3sInlineScriptCommand(parsed, shell, commandName), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
return { remoteCommand: buildK3sStdinScriptCommand(parsed, shell, commandName), requiresStdin: true, invocationKind: "helper", stdinPrefix: shellScriptStdinPrefix() };
|
|
}
|
|
|
|
function buildK3sStdinScriptCommand(parsed: K3sTargetOptions, shell: "sh" | "bash", commandName: string): string {
|
|
if (parsed.namespace === null && parsed.resource === null) return buildK3sHostScriptCommand(parsed, shell, commandName);
|
|
if (parsed.namespace === null) throw new Error(`${commandName} target requires --namespace <name>`);
|
|
if (parsed.selector !== null) throw new Error(`${commandName} does not support --selector`);
|
|
if (parsed.resource === null) throw new Error(`${commandName} target requires --deployment <name>, --pod <name> or --resource <type/name>`);
|
|
if (parsed.tty) throw new Error(`${commandName} does not support --tty; stdin is reserved for the shell body`);
|
|
const kubectlArgs = [
|
|
"exec",
|
|
"-i",
|
|
"-n", parsed.namespace,
|
|
parsed.resource,
|
|
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
|
...parsed.kubectlOptions,
|
|
"--",
|
|
...withK3sWorkspace(parsed, [shell, "-s", "--", ...parsed.command]),
|
|
];
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
|
}
|
|
|
|
function buildK3sInlineScriptCommand(parsed: K3sTargetOptions, shell: "sh" | "bash", commandName: string): string {
|
|
if (parsed.command.length === 0) throw new Error(`${commandName} -- requires a command`);
|
|
if (parsed.command.length !== 1) throw new Error(`${commandName} -- requires exactly one shell command string; use exec/argv for direct argv commands`);
|
|
if (parsed.selector !== null) throw new Error(`${commandName} -- does not support --selector`);
|
|
if (parsed.tty) throw new Error(`${commandName} does not support --tty; stdin is reserved for the shell body`);
|
|
if (parsed.stdin) throw new Error(`${commandName} -- does not accept --stdin`);
|
|
const command = [shell, "-c", shellScriptWithCompatibility(parsed.command[0] ?? "")];
|
|
if (parsed.namespace === null && parsed.resource === null) {
|
|
if (parsed.container !== null) throw new Error(`${commandName} without a workload does not accept --container`);
|
|
if (parsed.workspace !== null) throw new Error(`${commandName} without a workload does not accept --workdir`);
|
|
if (parsed.kubectlOptions.length > 0) throw new Error(`${commandName} without a workload does not accept kubectl log options`);
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, ...command]);
|
|
}
|
|
if (parsed.namespace === null) throw new Error(`${commandName} target requires --namespace <name>`);
|
|
if (parsed.resource === null) throw new Error(`${commandName} target requires --deployment <name>, --pod <name> or --resource <type/name>`);
|
|
const kubectlArgs = [
|
|
"exec",
|
|
"-n", parsed.namespace,
|
|
parsed.resource,
|
|
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
|
...parsed.kubectlOptions,
|
|
"--",
|
|
...withK3sWorkspace(parsed, command),
|
|
];
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
|
}
|
|
|
|
function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs {
|
|
const parsed = parseK3sTargetOptions(args, "ssh k3s apply-patch", { requireCommand: false, allowCommand: true });
|
|
if (parsed.namespace === null) throw new Error("ssh k3s apply-patch requires --namespace <name>");
|
|
if (parsed.resource === null) throw new Error("ssh k3s apply-patch requires --deployment <name>, --pod <name> or --resource <type/name>");
|
|
if (parsed.tty) throw new Error("ssh k3s apply-patch does not support --tty; stdin is reserved for the patch body");
|
|
if (parsed.stdin) throw new Error("ssh k3s apply-patch does not accept --stdin; stdin is always the patch body");
|
|
if (parsed.shell !== null) throw new Error("ssh k3s apply-patch does not accept --shell");
|
|
if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s apply-patch does not accept kubectl log options");
|
|
const kubectlArgs = [
|
|
"exec",
|
|
"-i",
|
|
"-n", parsed.namespace,
|
|
parsed.resource,
|
|
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
|
"--",
|
|
...withK3sWorkspace(parsed, ["sh", "-s", "--", ...parsed.command]),
|
|
];
|
|
const wrapper = podApplyPatchStdinWrapper();
|
|
return {
|
|
remoteCommand: shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]),
|
|
requiresStdin: true,
|
|
invocationKind: "helper",
|
|
stdinPrefix: wrapper.prefix,
|
|
stdinSuffix: wrapper.suffix,
|
|
};
|
|
}
|
|
|
|
function buildK3sHostScriptCommand(parsed: K3sTargetOptions, shell: "sh" | "bash", commandName: string): string {
|
|
if (parsed.tty) throw new Error(`${commandName} does not support --tty; stdin is reserved for the shell body`);
|
|
if (parsed.stdin) throw new Error(`${commandName} does not accept --stdin; stdin is always the shell body`);
|
|
if (parsed.container !== null) throw new Error(`${commandName} without a workload does not accept --container`);
|
|
if (parsed.workspace !== null) throw new Error(`${commandName} without a workload does not accept --workdir`);
|
|
if (parsed.kubectlOptions.length > 0) throw new Error(`${commandName} without a workload does not accept kubectl log options`);
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, shell, "-s", "--", ...parsed.command]);
|
|
}
|
|
|
|
function buildK3sLogsCommand(args: string[]): string {
|
|
const parsed = parseK3sTargetOptions(args, "ssh k3s logs", { requireCommand: false, allowSelector: true });
|
|
if (parsed.namespace === null) throw new Error("ssh k3s logs requires --namespace <name>");
|
|
if (parsed.resource === null && parsed.selector === null) throw new Error("ssh k3s logs requires --deployment <name>, --pod <name>, --resource <type/name> or --selector <label-selector>");
|
|
if (parsed.resource !== null && parsed.selector !== null) throw new Error("ssh k3s logs accepts either a resource or --selector, not both");
|
|
if (parsed.stdin || parsed.tty) throw new Error("ssh k3s logs does not support --stdin or --tty");
|
|
if (parsed.workspace !== null) throw new Error("ssh k3s logs does not accept --workdir");
|
|
const kubectlArgs = [
|
|
"logs",
|
|
"-n", parsed.namespace,
|
|
...(parsed.selector === null ? [parsed.resource ?? ""] : ["-l", parsed.selector]),
|
|
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
|
...parsed.kubectlOptions,
|
|
];
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
|
}
|
|
|
|
function parseK3sTargetOptions(args: string[], commandName: string, options: ParseK3sTargetOptionsOptions): K3sTargetOptions {
|
|
let namespace: string | null = null;
|
|
let resource: string | null = null;
|
|
let selector: string | null = null;
|
|
let container: string | null = null;
|
|
let workspace: string | null = null;
|
|
let stdin = false;
|
|
let tty = false;
|
|
let shell: string | null = null;
|
|
const kubectlOptions: string[] = [];
|
|
const command: string[] = [];
|
|
let afterDoubleDash = false;
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (afterDoubleDash) {
|
|
command.push(arg);
|
|
continue;
|
|
}
|
|
if (arg === "--") {
|
|
afterDoubleDash = true;
|
|
continue;
|
|
}
|
|
if (arg === "--namespace" || arg === "-n") {
|
|
namespace = k3sOptionValue(args, index, `${commandName} ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const namespaceValue = k3sEqualsOptionValue(arg, "--namespace", commandName);
|
|
if (namespaceValue !== null) {
|
|
namespace = namespaceValue;
|
|
continue;
|
|
}
|
|
if (arg === "--deployment" || arg === "--deploy") {
|
|
resource = `deployment/${k3sOptionValue(args, index, `${commandName} ${arg}`)}`;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const deploymentValue = k3sEqualsOptionValue(arg, "--deployment", commandName) ?? k3sEqualsOptionValue(arg, "--deploy", commandName);
|
|
if (deploymentValue !== null) {
|
|
resource = `deployment/${deploymentValue}`;
|
|
continue;
|
|
}
|
|
if (arg === "--pod") {
|
|
resource = `pod/${k3sOptionValue(args, index, `${commandName} ${arg}`)}`;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const podValue = k3sEqualsOptionValue(arg, "--pod", commandName);
|
|
if (podValue !== null) {
|
|
resource = `pod/${podValue}`;
|
|
continue;
|
|
}
|
|
if (arg === "--resource") {
|
|
resource = normalizeK3sResource(k3sOptionValue(args, index, `${commandName} ${arg}`));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const resourceValue = k3sEqualsOptionValue(arg, "--resource", commandName);
|
|
if (resourceValue !== null) {
|
|
resource = normalizeK3sResource(resourceValue);
|
|
continue;
|
|
}
|
|
if (arg === "--selector" || arg === "-l") {
|
|
if (options.allowSelector !== true) throw new Error(`${commandName} does not support ${arg}`);
|
|
selector = k3sOptionValue(args, index, `${commandName} ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const selectorValue = k3sEqualsOptionValue(arg, "--selector", commandName) ?? k3sEqualsOptionValue(arg, "-l", commandName);
|
|
if (selectorValue !== null) {
|
|
if (options.allowSelector !== true) throw new Error(`${commandName} does not support ${arg.split("=", 1)[0]}`);
|
|
selector = selectorValue;
|
|
continue;
|
|
}
|
|
if (arg === "--container" || arg === "-c") {
|
|
container = k3sOptionValue(args, index, `${commandName} ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const containerValue = k3sEqualsOptionValue(arg, "--container", commandName);
|
|
if (containerValue !== null) {
|
|
container = containerValue;
|
|
continue;
|
|
}
|
|
if (arg === "--workdir" || arg === "--cwd") {
|
|
workspace = k3sWorkspaceValue(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const workdirValue = k3sEqualsOptionValue(arg, "--workdir", commandName) ?? k3sEqualsOptionValue(arg, "--cwd", commandName);
|
|
if (workdirValue !== null) {
|
|
workspace = k3sWorkspaceValue(workdirValue, `${commandName} ${arg.split("=", 1)[0]}`);
|
|
continue;
|
|
}
|
|
if (arg === "--shell") {
|
|
if (!options.allowShell) throw new Error(`${commandName} does not support --shell`);
|
|
shell = k3sScriptShell(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const shellValue = k3sEqualsOptionValue(arg, "--shell", commandName);
|
|
if (shellValue !== null) {
|
|
if (!options.allowShell) throw new Error(`${commandName} does not support --shell`);
|
|
shell = k3sScriptShell(shellValue, `${commandName} --shell`);
|
|
continue;
|
|
}
|
|
if (arg === "--stdin" || arg === "-i") {
|
|
stdin = true;
|
|
continue;
|
|
}
|
|
if (arg === "--tty" || arg === "-t") {
|
|
tty = true;
|
|
continue;
|
|
}
|
|
if (arg === "--tail") {
|
|
kubectlOptions.push("--tail", optionalPositiveInt(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} --tail`));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const tailValue = k3sEqualsOptionValue(arg, "--tail", commandName);
|
|
if (tailValue !== null) {
|
|
kubectlOptions.push("--tail", optionalPositiveInt(tailValue, `${commandName} --tail`));
|
|
continue;
|
|
}
|
|
if (arg === "--since" || arg === "--since-time" || arg === "--limit-bytes") {
|
|
kubectlOptions.push(arg, k3sOptionValue(args, index, `${commandName} ${arg}`));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const sinceValue = k3sEqualsOptionValue(arg, "--since", commandName);
|
|
const sinceTimeValue = k3sEqualsOptionValue(arg, "--since-time", commandName);
|
|
const limitBytesValue = k3sEqualsOptionValue(arg, "--limit-bytes", commandName);
|
|
if (sinceValue !== null || sinceTimeValue !== null || limitBytesValue !== null) {
|
|
const optionName = sinceValue !== null ? "--since" : sinceTimeValue !== null ? "--since-time" : "--limit-bytes";
|
|
const optionValue = sinceValue ?? sinceTimeValue ?? limitBytesValue;
|
|
kubectlOptions.push(optionName, optionValue ?? "");
|
|
continue;
|
|
}
|
|
if (arg === "--previous" || arg === "--timestamps" || arg === "--all-containers" || arg === "--prefix") {
|
|
kubectlOptions.push(arg);
|
|
continue;
|
|
}
|
|
if (arg === "--follow" || arg === "-f") {
|
|
throw new Error(`${commandName} does not support ${arg}; use a bounded logs command and poll again`);
|
|
}
|
|
if (arg.startsWith("-")) throw new Error(`unsupported ${commandName} option: ${arg}`);
|
|
if (resource === null) {
|
|
resource = normalizeK3sResource(arg);
|
|
continue;
|
|
}
|
|
throw new Error(`unexpected ${commandName} argument before --: ${arg}`);
|
|
}
|
|
|
|
if (options.requireCommand && command.length === 0) throw new Error(`${commandName} requires -- <command> [args...]`);
|
|
if (!options.requireCommand && options.allowCommand !== true && command.length > 0) throw new Error(`${commandName} does not accept a command after --`);
|
|
return { namespace, resource, selector, container, workspace, stdin, tty, shell, command, kubectlOptions };
|
|
}
|
|
|
|
function k3sEqualsOptionValue(arg: string, option: string, commandName: string): string | null {
|
|
const prefix = `${option}=`;
|
|
if (!arg.startsWith(prefix)) return null;
|
|
const value = arg.slice(prefix.length);
|
|
if (value.length === 0) throw new Error(`${commandName} ${option} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function k3sOptionValue(args: string[], index: number, option: string): string {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error(`${option} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function k3sWorkspaceValue(value: string, option: string): string {
|
|
if (!value.startsWith("/")) throw new Error(`${option} must be an absolute pod workspace path`);
|
|
return value;
|
|
}
|
|
|
|
function withK3sWorkspace(parsed: K3sTargetOptions, command: string[]): string[] {
|
|
if (parsed.workspace === null) return command;
|
|
if (command.length === 0) throw new Error("ssh k3s workspace route requires a command to execute");
|
|
const cwdScript = [
|
|
"if ! cd \"$1\"; then",
|
|
"rc=$?",
|
|
"printf 'UNIDESK_K3S_CWD_HINT cwd=%s message=%s\\n' \"$1\" '/... is interpreted as remote cwd, not container; use :<container> or --container <container> for container selection.' >&2",
|
|
"exit \"$rc\"",
|
|
"fi",
|
|
"shift",
|
|
"exec \"$@\"",
|
|
].join("\n");
|
|
return ["sh", "-c", cwdScript, "unidesk-cwd", parsed.workspace, ...command];
|
|
}
|
|
|
|
function normalizeK3sResource(value: string): string {
|
|
if (value.includes("/")) return value;
|
|
return `pod/${value}`;
|
|
}
|
|
|
|
function normalizeK3sRouteResource(value: string): string {
|
|
if (value.startsWith("deploy/")) return `deployment/${value.slice("deploy/".length)}`;
|
|
if (value.startsWith("po/")) return `pod/${value.slice("po/".length)}`;
|
|
if (value.startsWith("pod:")) return `pod/${value.slice("pod:".length)}`;
|
|
if (value.startsWith("po:")) return `pod/${value.slice("po:".length)}`;
|
|
if (value.startsWith("pods:")) return `pod/${value.slice("pods:".length)}`;
|
|
if (value.includes("/")) return value;
|
|
return `deployment/${value}`;
|
|
}
|
|
|
|
function k3sScriptShell(value: string, option: string): string {
|
|
if (!/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${option} must be a shell executable name or path without whitespace`);
|
|
return value;
|
|
}
|
|
|
|
function shellScriptPrelude(): string {
|
|
return `${sshUserToolPathPrelude}\n${sshShellCompatibilityPrelude}`;
|
|
}
|
|
|
|
function shellScriptWithCompatibility(command: string): string {
|
|
return `${shellScriptPrelude()}\n${command}`;
|
|
}
|
|
|
|
function shellScriptStdinPrefix(): string {
|
|
return `${shellScriptPrelude()}\n`;
|
|
}
|
|
|
|
function withTransHostEnvironment(route: ParsedSshRoute, parsed: ParsedSshArgs): ParsedSshArgs {
|
|
if (route.plane !== "host" || parsed.remoteCommand === null) return parsed;
|
|
const preludes: string[] = [];
|
|
const ownershipPrelude = transHostWorkspaceOwnershipPrelude(route.workspace);
|
|
if (ownershipPrelude !== null) preludes.push(ownershipPrelude);
|
|
const rule = readTransHostProxyEnvRule(route.providerId);
|
|
if (rule !== null && rule.applyTo === "host-posix-commands") preludes.push(transHostProxyEnvPrelude(rule));
|
|
if (preludes.length === 0) return parsed;
|
|
return { ...parsed, remoteCommand: `${preludes.join("; ")}; ${parsed.remoteCommand}` };
|
|
}
|
|
|
|
function transHostWorkspaceOwnershipPrelude(workspace: string | null): string | null {
|
|
if (workspace === null || !/^\/mnt\/[A-Za-z](?:\/|$)/u.test(workspace)) return null;
|
|
return [
|
|
'if [ "$(id -u)" -eq 0 ]; then',
|
|
' UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID="$(stat -c %u . 2>/dev/null || true)"',
|
|
' case "$UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID" in',
|
|
' ""|*[!0-9]*|0) ;;',
|
|
' *) export SUDO_UID="$UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID" ;;',
|
|
' esac',
|
|
' export UNIDESK_TRANS_HOST_WORKSPACE_OWNER_UID',
|
|
'fi',
|
|
].join("\n");
|
|
}
|
|
|
|
function transHostProxyEnvPrelude(rule: TransHostProxyEnvRule): string {
|
|
const envFile = shellQuote(rule.envFile);
|
|
return [
|
|
`UNIDESK_TRANS_HOST_PROXY_ENV=${envFile}`,
|
|
'if [ -f "$UNIDESK_TRANS_HOST_PROXY_ENV" ]; then set -a; . "$UNIDESK_TRANS_HOST_PROXY_ENV"; unidesk_trans_host_proxy_env_rc=$?; set +a; [ "$unidesk_trans_host_proxy_env_rc" -eq 0 ] || exit "$unidesk_trans_host_proxy_env_rc"; fi',
|
|
`export UNIDESK_TRANS_HOST_PROXY_ENV_SOURCE=${shellQuote(rule.configPathLabel)}`,
|
|
].join("; ");
|
|
}
|
|
|
|
function buildShellCommand(args: string[], shell: "sh" | "bash", commandName: string): ParsedSshArgs {
|
|
const scriptArgs: string[] = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === "--") {
|
|
if (scriptArgs.length === 0) {
|
|
const command = args.slice(index + 1);
|
|
if (command.length === 0) throw new Error(`${commandName} -- requires a command`);
|
|
if (command.length !== 1) throw new Error(`${commandName} -- requires exactly one shell command string; use argv or a direct subcommand for direct argv commands`);
|
|
return { remoteCommand: shellArgv([shell, "-c", shellScriptWithCompatibility(command[0] ?? "")]), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
scriptArgs.push(...args.slice(index + 1));
|
|
break;
|
|
}
|
|
if (arg.startsWith("-")) throw new Error(`unsupported ${commandName} option: ${arg}`);
|
|
scriptArgs.push(arg);
|
|
}
|
|
return { remoteCommand: shellArgv([shell, "-s", "--", ...scriptArgs]), requiresStdin: true, invocationKind: "helper", stdinPrefix: shellScriptStdinPrefix() };
|
|
}
|
|
|
|
function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } {
|
|
const toolMarker = "__UNIDESK_APPLY_PATCH_TOOL__";
|
|
const patchMarker = "__UNIDESK_APPLY_PATCH_PAYLOAD__";
|
|
if (remoteApplyPatchSource.includes(toolMarker)) throw new Error("remote apply_patch source contains reserved heredoc marker");
|
|
return {
|
|
prefix: [
|
|
"set -eu",
|
|
'UNIDESK_SSH_TOOL_DIR="${UNIDESK_SSH_TOOL_DIR:-/tmp/unidesk-ssh-tools}"',
|
|
'mkdir -p "$UNIDESK_SSH_TOOL_DIR"',
|
|
`cat > "$UNIDESK_SSH_TOOL_DIR/apply_patch" <<'${toolMarker}'`,
|
|
remoteApplyPatchSource.trimEnd(),
|
|
toolMarker,
|
|
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"',
|
|
`"$UNIDESK_SSH_TOOL_DIR/apply_patch" "$@" <<'${patchMarker}'`,
|
|
].join("\n") + "\n",
|
|
suffix: `\n${patchMarker}\n`,
|
|
};
|
|
}
|
|
|
|
function buildPythonStdinCommand(args: string[]): string {
|
|
const pythonArgs = args.map(shellQuote).join(" ");
|
|
const execArgs = pythonArgs.length > 0 ? ` "$UNIDESK_SSH_PY_FILE" ${pythonArgs}` : ' "$UNIDESK_SSH_PY_FILE"';
|
|
return [
|
|
'UNIDESK_SSH_PY_FILE="$(mktemp /tmp/unidesk-ssh-py.XXXXXX.py)" || exit 1',
|
|
`trap 'rm -f "$UNIDESK_SSH_PY_FILE"' EXIT`,
|
|
'cat > "$UNIDESK_SSH_PY_FILE"',
|
|
`python3 -u${execArgs}`,
|
|
].join("; ");
|
|
}
|
|
|
|
const remoteToolSources: Record<SshHelperName, string> = {
|
|
apply_patch: remoteApplyPatchSource,
|
|
glob: remoteGlobSource,
|
|
"skill-discover": remoteSkillDiscoverSource,
|
|
};
|
|
|
|
function remoteToolBootstrapCommand(helpers: readonly SshHelperName[] = []): string {
|
|
const uniqueHelpers = [...new Set(helpers)];
|
|
if (uniqueHelpers.length === 0) return "";
|
|
const commands = [
|
|
"UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools",
|
|
'mkdir -p "$UNIDESK_SSH_TOOL_DIR"',
|
|
];
|
|
for (const helper of uniqueHelpers) {
|
|
const source = remoteToolSources[helper];
|
|
const encoded = Buffer.from(source, "utf8").toString("base64");
|
|
commands.push(`printf %s ${shellQuote(encoded)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/${helper}"`);
|
|
commands.push(`chmod 700 "$UNIDESK_SSH_TOOL_DIR/${helper}"`);
|
|
}
|
|
commands.push('export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"');
|
|
return commands.join("; ");
|
|
}
|
|
|
|
export function wrapSshRemoteCommand(command: string | null, helpers: readonly SshHelperName[] = []): string {
|
|
const bootstrap = remoteToolBootstrapCommand(helpers);
|
|
const prelude = [sshUserToolPathPrelude, bootstrap].filter((part) => part.length > 0).join("; ");
|
|
const prefix = prelude.length > 0 ? `${prelude}; ` : "";
|
|
if (command === null) return `${prefix}exec "\${SHELL:-/bin/bash}" -l`;
|
|
return `${prefix}stty -echo 2>/dev/null || true; ${command}`;
|
|
}
|
|
|
|
export function safeProviderId(providerId: string): string {
|
|
return /^[A-Za-z0-9_.-]{1,64}$/u.test(providerId) ? providerId : "<provider>";
|
|
}
|
|
|
|
export {
|
|
classifySshTcpPoolFailure,
|
|
createPosixApplyPatchFileSystem,
|
|
createSshStderrForwarder,
|
|
createSshStdoutForwarder,
|
|
createWindowsApplyPatchFileSystem,
|
|
formatSshFailureHint,
|
|
formatSshRuntimeTimeoutHint,
|
|
formatSshRuntimeTimingHint,
|
|
formatSshStdoutTruncationHint,
|
|
formatSshTcpPoolHint,
|
|
formatSshTruncationCompletionSummary,
|
|
remoteCommandForRoute,
|
|
runSsh,
|
|
runSshCommandCapture,
|
|
sshCaptureBackendPlan,
|
|
sshFailureHint,
|
|
sshRuntimeTimeoutHint,
|
|
sshRuntimeTimeoutMs,
|
|
sshRuntimeTimingHint,
|
|
sshSlowWarningThresholdMs,
|
|
sshStderrStreamMaxBytes,
|
|
sshStdoutStreamMaxBytes,
|
|
sshStdoutTruncationHint,
|
|
sshTcpPoolHint,
|
|
sshTruncationCompletionSummary,
|
|
sshWindowsPlaneHint,
|
|
} from "./ssh-runtime";
|
|
export type { SshStreamForwarder } from "./ssh-runtime";
|
|
export { windowsFsReadOnlyScript } from "./ssh-windows-fs";
|