[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
304
domain/quickConnect.ts
Normal file
304
domain/quickConnect.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
export interface QuickConnectTarget {
|
||||
hostname: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
interface QuickConnectParseResult {
|
||||
target: QuickConnectTarget | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Test whether a string looks like a bare (un-bracketed) IPv6 address.
|
||||
* Must have only hex digits and colons, with either:
|
||||
* - A "::" shorthand (unambiguously IPv6), or
|
||||
* - Exactly 7 colons (full 8-group notation like 2607:f130:0:179:0:0:b0df:eec4)
|
||||
* This avoids false positives on MAC addresses (6 groups, 5 colons). */
|
||||
const BARE_IPV6_RE = /^[a-fA-F0-9:]+$/;
|
||||
const isBareIPv6 = (s: string): boolean => {
|
||||
if (!BARE_IPV6_RE.test(s)) return false;
|
||||
if (s.includes('::')) return true;
|
||||
return (s.match(/:/g) || []).length === 7;
|
||||
};
|
||||
|
||||
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Pattern: [user@]hostname[:port]
|
||||
// Hostname can be IP (v4 or v6 in brackets) or domain name
|
||||
const regex = /^(?:([^@]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/;
|
||||
const match = trimmed.match(regex);
|
||||
|
||||
// If the main regex fails, try bare IPv6: [user@]ipv6_address
|
||||
// Bare IPv6 contains colons so the main regex can't distinguish host:port.
|
||||
// Port must be specified via brackets: [ipv6]:port
|
||||
if (!match) {
|
||||
const bareIpv6Regex = /^(?:([^@]+)@)?([a-fA-F0-9:]+)$/;
|
||||
const bareMatch = trimmed.match(bareIpv6Regex);
|
||||
if (bareMatch) {
|
||||
const [, bareUser, bareHost] = bareMatch;
|
||||
if (isBareIPv6(bareHost)) {
|
||||
return {
|
||||
hostname: bareHost,
|
||||
username: bareUser || undefined,
|
||||
port: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, username, hostname, portStr] = match;
|
||||
|
||||
// Validate hostname looks like an IP or domain
|
||||
const ipv4Regex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
||||
const ipv6Regex = /^\[?[a-fA-F0-9:]+\]?$/;
|
||||
const domainRegex =
|
||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
|
||||
if (
|
||||
!ipv4Regex.test(hostname) &&
|
||||
!ipv6Regex.test(hostname) &&
|
||||
!domainRegex.test(hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = portStr ? parseInt(portStr, 10) : undefined;
|
||||
if (port !== undefined && (isNaN(port) || port < 1 || port > 65535)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: hostname.replace(/^\[|\]$/g, ""), // Remove IPv6 brackets
|
||||
username: username || undefined,
|
||||
port,
|
||||
};
|
||||
};
|
||||
|
||||
const sshArgOptions = new Set([
|
||||
"-b",
|
||||
"-c",
|
||||
"-D",
|
||||
"-E",
|
||||
"-F",
|
||||
"-i",
|
||||
"-I",
|
||||
"-J",
|
||||
"-L",
|
||||
"-m",
|
||||
"-O",
|
||||
"-P",
|
||||
"-R",
|
||||
"-S",
|
||||
"-W",
|
||||
"-w",
|
||||
]);
|
||||
|
||||
const parseSshOption = (
|
||||
raw: string,
|
||||
nextToken?: string,
|
||||
): { key: string; value: string; consumedNext: boolean } | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parts = trimmed.split("=");
|
||||
if (parts.length >= 2) {
|
||||
const key = parts[0]?.trim();
|
||||
const value = parts.slice(1).join("=").trim();
|
||||
if (key && value) {
|
||||
return { key, value, consumedNext: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (nextToken && !nextToken.startsWith("-")) {
|
||||
return { key: trimmed, value: nextToken, consumedNext: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseSshCommand = (input: string): QuickConnectParseResult | null => {
|
||||
const trimmed = input.trim();
|
||||
if (!/^ssh(\s|$)/i.test(trimmed)) return null;
|
||||
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
if (tokens.length < 2) return null;
|
||||
|
||||
const warnings: string[] = [];
|
||||
let username: string | undefined;
|
||||
let optionUsername: string | undefined;
|
||||
let port: number | undefined;
|
||||
let optionPort: number | undefined;
|
||||
let portInvalid = false;
|
||||
let optionHostname: string | undefined;
|
||||
let hostToken: string | undefined;
|
||||
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
if (token === "-p") {
|
||||
const value = tokens[i + 1];
|
||||
if (value) {
|
||||
port = parseInt(value, 10);
|
||||
if (Number.isNaN(port)) portInvalid = true;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("-p") && token.length > 2) {
|
||||
const value = token.replace(/^-p=?/, "");
|
||||
if (value) {
|
||||
port = parseInt(value, 10);
|
||||
if (Number.isNaN(port)) portInvalid = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === "-l") {
|
||||
const value = tokens[i + 1];
|
||||
if (value) {
|
||||
username = value;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("-l") && token.length > 2) {
|
||||
const value = token.replace(/^-l=?/, "");
|
||||
if (value) username = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === "-o") {
|
||||
const optionToken = tokens[i + 1];
|
||||
if (optionToken) {
|
||||
const nextToken = tokens[i + 2];
|
||||
const parsed = parseSshOption(optionToken, nextToken);
|
||||
if (parsed) {
|
||||
const key = parsed.key.toLowerCase();
|
||||
if (key === "port") {
|
||||
const parsedPort = parseInt(parsed.value, 10);
|
||||
if (Number.isNaN(parsedPort)) {
|
||||
portInvalid = true;
|
||||
} else {
|
||||
optionPort = parsedPort;
|
||||
}
|
||||
} else if (key === "user") {
|
||||
optionUsername = parsed.value;
|
||||
} else if (key === "hostname") {
|
||||
optionHostname = parsed.value;
|
||||
} else {
|
||||
warnings.push(`-o ${parsed.key}`);
|
||||
}
|
||||
i += parsed.consumedNext ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
warnings.push("-o");
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("-o") && token.length > 2) {
|
||||
const parsed = parseSshOption(token.slice(2), tokens[i + 1]);
|
||||
if (parsed) {
|
||||
const key = parsed.key.toLowerCase();
|
||||
if (key === "port") {
|
||||
const parsedPort = parseInt(parsed.value, 10);
|
||||
if (Number.isNaN(parsedPort)) {
|
||||
portInvalid = true;
|
||||
} else {
|
||||
optionPort = parsedPort;
|
||||
}
|
||||
} else if (key === "user") {
|
||||
optionUsername = parsed.value;
|
||||
} else if (key === "hostname") {
|
||||
optionHostname = parsed.value;
|
||||
} else {
|
||||
warnings.push(`-o ${parsed.key}`);
|
||||
}
|
||||
if (parsed.consumedNext) i++;
|
||||
continue;
|
||||
}
|
||||
warnings.push("-o");
|
||||
}
|
||||
|
||||
if (sshArgOptions.has(token)) {
|
||||
warnings.push(token);
|
||||
const next = tokens[i + 1];
|
||||
if (next) i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("-")) {
|
||||
warnings.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hostToken) {
|
||||
hostToken = token;
|
||||
} else {
|
||||
warnings.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hostToken) return null;
|
||||
|
||||
const base = optionHostname
|
||||
? parseDirectTarget(optionHostname)
|
||||
: parseDirectTarget(hostToken);
|
||||
if (!base) return null;
|
||||
|
||||
if (portInvalid) return null;
|
||||
|
||||
const resolvedPort =
|
||||
port !== undefined && !Number.isNaN(port)
|
||||
? port
|
||||
: optionPort !== undefined && !Number.isNaN(optionPort)
|
||||
? optionPort
|
||||
: base.port;
|
||||
if (
|
||||
resolvedPort !== undefined &&
|
||||
(Number.isNaN(resolvedPort) || resolvedPort < 1 || resolvedPort > 65535)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
target: {
|
||||
hostname: base.hostname,
|
||||
username: optionUsername || username || base.username,
|
||||
port: resolvedPort,
|
||||
},
|
||||
warnings: Array.from(new Set(warnings)),
|
||||
};
|
||||
};
|
||||
|
||||
// Parse user@host:port or ssh command formats with warning details
|
||||
export function parseQuickConnectInputWithWarnings(
|
||||
input: string,
|
||||
): QuickConnectParseResult {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { target: null, warnings: [] };
|
||||
|
||||
const sshTarget = parseSshCommand(trimmed);
|
||||
if (sshTarget) return sshTarget;
|
||||
|
||||
return { target: parseDirectTarget(trimmed), warnings: [] };
|
||||
}
|
||||
|
||||
// Parse user@host:port or ssh command formats
|
||||
export function parseQuickConnectInput(
|
||||
input: string,
|
||||
): QuickConnectTarget | null {
|
||||
return parseQuickConnectInputWithWarnings(input).target;
|
||||
}
|
||||
|
||||
// Check if input looks like a quick connect address
|
||||
export function isQuickConnectInput(input: string): boolean {
|
||||
return parseQuickConnectInput(input) !== null;
|
||||
}
|
||||
Reference in New Issue
Block a user