import * as NodeCrypto from "node:crypto"; import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Scope from "effect/Scope";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts";import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; /** * ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always * spawned directly — cmd.exe shell mode would re-tokenize arguments such as * identity-file paths containing spaces. */const sshCommandForPlatform = (platform: NodeJS.Platform): string => platform === "win32" ? "ssh.exe" : "ssh"; export const resolveSshCommand = Effect.map(HostProcessPlatform, sshCommandForPlatform); const encoder = new TextEncoder(); export interface SshCommandResult { readonly stdout: string; readonly stderr: string;} export interface RunSshCommandOptions extends SshAuthOptions { readonly preHostArgs?: ReadonlyArray<string>; readonly remoteCommandArgs?: ReadonlyArray<string>; readonly stdin?: string; readonly timeoutMs?: number;} export function parseSshResolveOutput(alias: string, stdout: string): DesktopSshEnvironmentTarget { const values = new Map<string, string>(); for (const line of stdout.split(/\r?\n/u)) { const trimmed = line.trim(); if (trimmed.length === 0) { continue; } const [key, ...rest] = trimmed.split(/\s+/u); if (!key || rest.length === 0 || values.has(key)) { continue; } values.set(key, rest.join(" ").trim()); } const hostname = values.get("hostname")?.trim() || alias; const username = values.get("user")?.trim() || null; const rawPort = values.get("port")?.trim() ?? ""; const parsedPort = Number.parseInt(rawPort, 10); return { alias, hostname, username, port: Number.isInteger(parsedPort) ? parsedPort : null, };} export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string { return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`;} export function remoteStateKey(target: DesktopSshEnvironmentTarget): string { return NodeCrypto.createHash("sha256") .update(targetConnectionKey(target)) .digest("hex") .slice(0, 16);} export function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { const destination = target.alias.trim() || target.hostname.trim(); if (destination.length === 0) { throw new Error("SSH target is missing its alias/hostname."); } return target.username ? `${target.username}@${destination}` : destination;} export const buildSshHostSpecEffect = ( target: DesktopSshEnvironmentTarget,): Effect.Effect<string, SshInvalidTargetError> => Effect.try({ try: () => buildSshHostSpec(target), catch: (cause) => new SshInvalidTargetError({ message: cause instanceof Error ? cause.message : "SSH target is invalid.", }), }); export function baseSshArgs( target: DesktopSshEnvironmentTarget, input?: { readonly batchMode?: "yes" | "no" },): string[] { return [ "-o", `BatchMode=${input?.batchMode ?? "no"}`, "-o", "ConnectTimeout=10", ...(target.port !== null ? ["-p", String(target.port)] : []), ];} export function getLastNonEmptyOutputLine(stdout: string): string | null { return ( stdout .trim() .split(/\r?\n/u) .map((entry) => entry.trim()) .findLast((entry) => entry.length > 0) ?? null );} export const collectProcessOutput = <E>( stream: Stream.Stream<Uint8Array, E>,): Effect.Effect<string, E> => stream.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), ); function redactSshErrorOutput(output: string): string { const redacted = output.replace( /("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu, "$1[redacted]$2", ); return redacted.length > MAX_SSH_ERROR_OUTPUT_LENGTH ? `${redacted.slice(0, MAX_SSH_ERROR_OUTPUT_LENGTH)}\n[truncated]` : redacted;} function normalizeSshErrorMessage(input: { readonly stdout?: string; readonly stderr: string; readonly fallbackMessage: string;}): string { const cleanedStderr = input.stderr.trim(); if (cleanedStderr.length > 0) { return cleanedStderr; } const cleanedStdout = input.stdout?.trim() ?? ""; return cleanedStdout.length > 0 ? cleanedStdout : input.fallbackMessage;} function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { return { alias: target.alias, hostname: target.hostname, username: target.username, port: target.port, };} function stdinStream(input: string | undefined) { return input === undefined ? Stream.empty : Stream.make(encoder.encode(input));} const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(function* ( target: DesktopSshEnvironmentTarget, input: RunSshCommandOptions, commandScope: Scope.Scope,): Effect.fn.Return< SshCommandResult, SshCommandError | SshInvalidTargetError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path> { const hostSpec = yield* buildSshHostSpecEffect(target); const environment = yield* buildSshChildEnvironment({ ...(input.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), ...(input.authSecret === undefined ? {} : { authSecret: input.authSecret }), }).pipe( Effect.mapError( (cause) => new SshCommandError({ command: ["ssh"], exitCode: null, stderr: "", message: "Failed to prepare SSH authentication helpers.", cause, }), ), ); const args = [ ...baseSshArgs(target, { batchMode: input.batchMode ?? (input.interactiveAuth ? "no" : "yes"), }), ...(input.preHostArgs ?? []), hostSpec, ...(input.remoteCommandArgs ?? []), ]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const sshCommand = yield* resolveSshCommand; yield* Effect.logDebug("ssh.command.start", { ...sshTargetLogFields(target), command: [sshCommand, ...args], hasStdin: input.stdin !== undefined, timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, }); const child = yield* spawner .spawn( ChildProcess.make(sshCommand, args, { env: environment, extendEnv: true, stdin: { stream: stdinStream(input.stdin), endOnDone: true, }, }), ) .pipe( Effect.provideService(Scope.Scope, commandScope), Effect.mapError( (cause) => new SshCommandError({ command: [sshCommand, ...args], exitCode: null, stderr: "", message: cause instanceof Error ? cause.message : `Failed to spawn SSH command for ${hostSpec}.`, cause, }), ), ); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectProcessOutput(child.stdout), collectProcessOutput(child.stderr), child.exitCode.pipe(Effect.map(Number)), ], { concurrency: "unbounded" }, ).pipe( Effect.mapError( (cause) => new SshCommandError({ command: ["ssh", ...args], exitCode: null, stderr: "", message: cause instanceof Error ? cause.message : `Failed to run SSH command for ${hostSpec}.`, cause, }), ), ); if (exitCode !== 0) { const diagnosticStdout = redactSshErrorOutput(stdout); yield* Effect.logWarning("ssh.command.failed", { ...sshTargetLogFields(target), command: ["ssh", ...args], exitCode, stdout: diagnosticStdout, stderr, }); return yield* new SshCommandError({ command: ["ssh", ...args], exitCode, stdout: diagnosticStdout, stderr, message: normalizeSshErrorMessage({ stdout: diagnosticStdout, stderr, fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`, }), }); } yield* Effect.logDebug("ssh.command.succeeded", { ...sshTargetLogFields(target), command: ["ssh", ...args], }); return { stdout, stderr };}); export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( target: DesktopSshEnvironmentTarget, input: RunSshCommandOptions = {},): Effect.fn.Return< SshCommandResult, SshCommandError | SshInvalidTargetError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path> { return yield* Effect.scopedWith((commandScope) => runSshCommandInScope(target, input, commandScope), ).pipe( Effect.timeoutOption(Duration.millis(input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS)), Effect.flatMap((result) => Option.match(result, { onSome: Effect.succeed, onNone: () => Effect.gen(function* () { yield* Effect.logWarning("ssh.command.timedOut", { ...sshTargetLogFields(target), timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, remoteCommandArgs: input.remoteCommandArgs ?? [], preHostArgs: input.preHostArgs ?? [], hasStdin: input.stdin !== undefined, }); return yield* new SshCommandError({ command: ["ssh"], exitCode: null, stderr: "", message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`, }); }), }), ), );}); export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(function* ( alias: string,): Effect.fn.Return< DesktopSshEnvironmentTarget, SshCommandError | SshInvalidTargetError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path> { const trimmedAlias = alias.trim(); if (trimmedAlias.length === 0) { return yield* new SshInvalidTargetError({ message: "SSH host alias is required." }); } yield* Effect.logDebug("ssh.target.resolve.start", { alias: trimmedAlias }); return yield* runSshCommand( { alias: trimmedAlias, hostname: trimmedAlias, username: null, port: null, }, { preHostArgs: ["-G"] }, ).pipe( Effect.map((result) => parseSshResolveOutput(trimmedAlias, result.stdout)), Effect.tap((target) => Effect.logDebug("ssh.target.resolve.succeeded", sshTargetLogFields(target)), ), Effect.catch((cause) => Effect.logDebug("ssh.target.resolve.fallback", { alias: trimmedAlias, cause }).pipe( Effect.as({ alias: trimmedAlias, hostname: trimmedAlias, username: null, port: null, }), ), ), );}); export function resolveRemoteT3CliPackageSpec(input: { readonly appVersion: string; readonly updateChannel: DesktopUpdateChannel; readonly isDevelopment?: boolean;}): string { const appVersion = input.appVersion.trim(); if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { return `t3@${appVersion}`; } if (input.isDevelopment) { return "t3@nightly"; } return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest";}
import * as NodeCrypto from "node:crypto";
import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts";
import { SshCommandError, SshInvalidTargetError } from "./errors.ts";
const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;
const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000;
/**
* ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always
* spawned directly — cmd.exe shell mode would re-tokenize arguments such as
* identity-file paths containing spaces.
*/
const sshCommandForPlatform = (platform: NodeJS.Platform): string =>
platform === "win32" ? "ssh.exe" : "ssh";
export const resolveSshCommand = Effect.map(HostProcessPlatform, sshCommandForPlatform);
const encoder = new TextEncoder();
export interface SshCommandResult {
readonly stdout: string;
readonly stderr: string;
}
export interface RunSshCommandOptions extends SshAuthOptions {
readonly preHostArgs?: ReadonlyArray<string>;
readonly remoteCommandArgs?: ReadonlyArray<string>;
readonly stdin?: string;
readonly timeoutMs?: number;
}
export function parseSshResolveOutput(alias: string, stdout: string): DesktopSshEnvironmentTarget {
const values = new Map<string, string>();
for (const line of stdout.split(/\r?\n/u)) {
const trimmed = line.trim();
if (trimmed.length === 0) {
continue;
}
const [key, ...rest] = trimmed.split(/\s+/u);
if (!key || rest.length === 0 || values.has(key)) {
continue;
}
values.set(key, rest.join(" ").trim());
}
const hostname = values.get("hostname")?.trim() || alias;
const username = values.get("user")?.trim() || null;
const rawPort = values.get("port")?.trim() ?? "";
const parsedPort = Number.parseInt(rawPort, 10);
return {
alias,
hostname,
username,
port: Number.isInteger(parsedPort) ? parsedPort : null,
};
}
export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string {
return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`;
}
export function remoteStateKey(target: DesktopSshEnvironmentTarget): string {
return NodeCrypto.createHash("sha256")
.update(targetConnectionKey(target))
.digest("hex")
.slice(0, 16);
}
export function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string {
const destination = target.alias.trim() || target.hostname.trim();
if (destination.length === 0) {
throw new Error("SSH target is missing its alias/hostname.");
}
return target.username ? `${target.username}@${destination}` : destination;
}
export const buildSshHostSpecEffect = (
target: DesktopSshEnvironmentTarget,
): Effect.Effect<string, SshInvalidTargetError> =>
Effect.try({
try: () => buildSshHostSpec(target),
catch: (cause) =>
new SshInvalidTargetError({
message: cause instanceof Error ? cause.message : "SSH target is invalid.",
}),
});
export function baseSshArgs(
target: DesktopSshEnvironmentTarget,
input?: { readonly batchMode?: "yes" | "no" },
): string[] {
return [
"-o",
`BatchMode=${input?.batchMode ?? "no"}`,
"-o",
"ConnectTimeout=10",
...(target.port !== null ? ["-p", String(target.port)] : []),
];
}
export function getLastNonEmptyOutputLine(stdout: string): string | null {
return (
stdout
.trim()
.split(/\r?\n/u)
.map((entry) => entry.trim())
.findLast((entry) => entry.length > 0) ?? null
);
}
export const collectProcessOutput = <E>(
stream: Stream.Stream<Uint8Array, E>,
): Effect.Effect<string, E> =>
stream.pipe(
Stream.decodeText(),
Stream.runFold(
() => "",
(acc, chunk) => acc + chunk,
),
);
function redactSshErrorOutput(output: string): string {
const redacted = output.replace(
/("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu,
"$1[redacted]$2",
);
return redacted.length > MAX_SSH_ERROR_OUTPUT_LENGTH
? `${redacted.slice(0, MAX_SSH_ERROR_OUTPUT_LENGTH)}\n[truncated]`
: redacted;
}
function normalizeSshErrorMessage(input: {
readonly stdout?: string;
readonly stderr: string;
readonly fallbackMessage: string;
}): string {
const cleanedStderr = input.stderr.trim();
if (cleanedStderr.length > 0) {
return cleanedStderr;
}
const cleanedStdout = input.stdout?.trim() ?? "";
return cleanedStdout.length > 0 ? cleanedStdout : input.fallbackMessage;
}
function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
return {
alias: target.alias,
hostname: target.hostname,
username: target.username,
port: target.port,
};
}
function stdinStream(input: string | undefined) {
return input === undefined ? Stream.empty : Stream.make(encoder.encode(input));
}
const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(function* (
target: DesktopSshEnvironmentTarget,
input: RunSshCommandOptions,
commandScope: Scope.Scope,
): Effect.fn.Return<
SshCommandResult,
SshCommandError | SshInvalidTargetError,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
> {
const hostSpec = yield* buildSshHostSpecEffect(target);
const environment = yield* buildSshChildEnvironment({
...(input.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
...(input.authSecret === undefined ? {} : { authSecret: input.authSecret }),
}).pipe(
Effect.mapError(
(cause) =>
new SshCommandError({
command: ["ssh"],
exitCode: null,
stderr: "",
message: "Failed to prepare SSH authentication helpers.",
cause,
}),
),
);
const args = [
...baseSshArgs(target, {
batchMode: input.batchMode ?? (input.interactiveAuth ? "no" : "yes"),
}),
...(input.preHostArgs ?? []),
hostSpec,
...(input.remoteCommandArgs ?? []),
];
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const sshCommand = yield* resolveSshCommand;
yield* Effect.logDebug("ssh.command.start", {
...sshTargetLogFields(target),
command: [sshCommand, ...args],
hasStdin: input.stdin !== undefined,
timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS,
});
const child = yield* spawner
.spawn(
ChildProcess.make(sshCommand, args, {
env: environment,
extendEnv: true,
stdin: {
stream: stdinStream(input.stdin),
endOnDone: true,
},
}),
)
.pipe(
Effect.provideService(Scope.Scope, commandScope),
Effect.mapError(
(cause) =>
new SshCommandError({
command: [sshCommand, ...args],
exitCode: null,
stderr: "",
message:
cause instanceof Error
? cause.message
: `Failed to spawn SSH command for ${hostSpec}.`,
cause,
}),
),
);
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectProcessOutput(child.stdout),
collectProcessOutput(child.stderr),
child.exitCode.pipe(Effect.map(Number)),
],
{ concurrency: "unbounded" },
).pipe(
Effect.mapError(
(cause) =>
new SshCommandError({
command: ["ssh", ...args],
exitCode: null,
stderr: "",
message:
cause instanceof Error ? cause.message : `Failed to run SSH command for ${hostSpec}.`,
cause,
}),
),
);
if (exitCode !== 0) {
const diagnosticStdout = redactSshErrorOutput(stdout);
yield* Effect.logWarning("ssh.command.failed", {
...sshTargetLogFields(target),
command: ["ssh", ...args],
exitCode,
stdout: diagnosticStdout,
stderr,
});
return yield* new SshCommandError({
command: ["ssh", ...args],
exitCode,
stdout: diagnosticStdout,
stderr,
message: normalizeSshErrorMessage({
stdout: diagnosticStdout,
stderr,
fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`,
}),
});
}
yield* Effect.logDebug("ssh.command.succeeded", {
...sshTargetLogFields(target),
command: ["ssh", ...args],
});
return { stdout, stderr };
});
export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* (
target: DesktopSshEnvironmentTarget,
input: RunSshCommandOptions = {},
): Effect.fn.Return<
SshCommandResult,
SshCommandError | SshInvalidTargetError,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
> {
return yield* Effect.scopedWith((commandScope) =>
runSshCommandInScope(target, input, commandScope),
).pipe(
Effect.timeoutOption(Duration.millis(input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS)),
Effect.flatMap((result) =>
Option.match(result, {
onSome: Effect.succeed,
onNone: () =>
Effect.gen(function* () {
yield* Effect.logWarning("ssh.command.timedOut", {
...sshTargetLogFields(target),
timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS,
remoteCommandArgs: input.remoteCommandArgs ?? [],
preHostArgs: input.preHostArgs ?? [],
hasStdin: input.stdin !== undefined,
});
return yield* new SshCommandError({
command: ["ssh"],
exitCode: null,
stderr: "",
message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`,
});
}),
}),
),
);
});
export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(function* (
alias: string,
): Effect.fn.Return<
DesktopSshEnvironmentTarget,
SshCommandError | SshInvalidTargetError,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
> {
const trimmedAlias = alias.trim();
if (trimmedAlias.length === 0) {
return yield* new SshInvalidTargetError({ message: "SSH host alias is required." });
}
yield* Effect.logDebug("ssh.target.resolve.start", { alias: trimmedAlias });
return yield* runSshCommand(
{
alias: trimmedAlias,
hostname: trimmedAlias,
username: null,
port: null,
},
{ preHostArgs: ["-G"] },
).pipe(
Effect.map((result) => parseSshResolveOutput(trimmedAlias, result.stdout)),
Effect.tap((target) =>
Effect.logDebug("ssh.target.resolve.succeeded", sshTargetLogFields(target)),
),
Effect.catch((cause) =>
Effect.logDebug("ssh.target.resolve.fallback", { alias: trimmedAlias, cause }).pipe(
Effect.as({
alias: trimmedAlias,
hostname: trimmedAlias,
username: null,
port: null,
}),
),
),
);
});
export function resolveRemoteT3CliPackageSpec(input: {
readonly appVersion: string;
readonly updateChannel: DesktopUpdateChannel;
readonly isDevelopment?: boolean;
}): string {
const appVersion = input.appVersion.trim();
if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) {
return `t3@${appVersion}`;
}
if (input.isDevelopment) {
return "t3@nightly";
}
return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest";
}