Add SSH launcher and cached remote runtime
This commit is contained in:
@@ -32,7 +32,30 @@ export interface IRemoteServerBootstrapOptions {
|
||||
nodeEnv?: string;
|
||||
}
|
||||
|
||||
export const defaultInstallRoot = '~/.git.zone/ide-server';
|
||||
export interface IRemoteEphemeralBootstrapOptions extends IRemoteServerBootstrapOptions {
|
||||
runtimeRoot: string;
|
||||
ideDataRoot?: string;
|
||||
nodePath?: string;
|
||||
}
|
||||
|
||||
export interface IRemoteEphemeralReadinessOptions {
|
||||
runtimeRoot: string;
|
||||
theiaPort: number;
|
||||
ideDataRoot?: string;
|
||||
nodePath?: string;
|
||||
waitSeconds?: number;
|
||||
}
|
||||
|
||||
export interface IRemoteEphemeralRuntimeCacheCheckOptions {
|
||||
runtimeRoot: string;
|
||||
runtimeSha256: string;
|
||||
markerFileName?: string;
|
||||
nodePath?: string;
|
||||
}
|
||||
|
||||
export const defaultIdeDataRoot = '~/.git.zone/ide';
|
||||
export const defaultInstallRoot = '~/.git.zone/ide/server';
|
||||
export const remoteEphemeralRuntimeMarkerFileName = '.gitzone-runtime-sha256';
|
||||
|
||||
export const createRemoteServerInstallPlan = (
|
||||
options: IRemoteServerInstallPlanOptions,
|
||||
@@ -70,12 +93,12 @@ export const createRemoteInstallCommand = (plan: IRemoteServerInstallPlan) => {
|
||||
const manifestJson = JSON.stringify(plan.manifest, undefined, 2);
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`mkdir -p ${quoteShellArg(plan.paths.versionRoot)} ${quoteShellArg(plan.paths.logsDir)}`,
|
||||
`cat > ${quoteShellArg(plan.paths.manifestPath)} <<'GITZONE_IDE_MANIFEST'`,
|
||||
`mkdir -p ${quoteRemotePath(plan.paths.versionRoot)} ${quoteRemotePath(plan.paths.logsDir)}`,
|
||||
`cat > ${quoteRemotePath(plan.paths.manifestPath)} <<'GITZONE_IDE_MANIFEST'`,
|
||||
manifestJson,
|
||||
'GITZONE_IDE_MANIFEST',
|
||||
`ln -sfn ${quoteShellArg(plan.paths.versionRoot)} ${quoteShellArg(plan.paths.currentLink)}`,
|
||||
`touch ${quoteShellArg(plan.markerFile)}`,
|
||||
`ln -sfn ${quoteRemotePath(plan.paths.versionRoot)} ${quoteRemotePath(plan.paths.currentLink)}`,
|
||||
`touch ${quoteRemotePath(plan.markerFile)}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -97,16 +120,95 @@ export const createRemoteBootstrapCommand = (options: IRemoteServerBootstrapOpti
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`mkdir -p ${quoteShellArg(plan.paths.logsDir)}`,
|
||||
`test -d ${quoteShellArg(options.workspacePath)}`,
|
||||
`cd ${quoteShellArg(options.workspacePath)}`,
|
||||
...Object.entries(env).map(([key, value]) => `export ${key}=${quoteShellArg(value)}`),
|
||||
`nohup pnpm --dir ${quoteShellArg(appDir)} start --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteShellArg(options.workspacePath)} > ${quoteShellArg(logFile)} 2>&1 < /dev/null &`,
|
||||
`mkdir -p ${quoteRemotePath(plan.paths.logsDir)}`,
|
||||
`test -d ${quoteRemotePath(options.workspacePath)} || { printf 'workspace path not found: %s\n' ${quoteShellArg(options.workspacePath)} >&2; exit 1; }`,
|
||||
`test -d ${quoteRemotePath(appDir)} || { printf 'remote Theia app not installed: %s\n' ${quoteShellArg(appDir)} >&2; exit 1; }`,
|
||||
'command -v pnpm >/dev/null || { printf \'pnpm not found on remote host\n\' >&2; exit 1; }',
|
||||
`cd ${quoteRemotePath(options.workspacePath)}`,
|
||||
...Object.entries(env).map(([key, value]) => {
|
||||
const renderedValue = key === 'GITZONE_IDE_WORKSPACE' ? quoteRemotePath(value) : quoteShellArg(value);
|
||||
return `export ${key}=${renderedValue}`;
|
||||
}),
|
||||
`nohup pnpm --dir ${quoteRemotePath(appDir)} start --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteRemotePath(options.workspacePath)} > ${quoteRemotePath(logFile)} 2>&1 < /dev/null &`,
|
||||
`printf 'theiaPort=%s\\n' ${options.theiaPort}`,
|
||||
`printf 'opencodePort=%s\\n' ${options.opencodePort}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralBootstrapOptions) => {
|
||||
const appDir = joinRemotePath(options.runtimeRoot, 'applications/remote-theia');
|
||||
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
|
||||
const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot;
|
||||
const logsDir = joinRemotePath(ideDataRoot, 'logs');
|
||||
const theiaConfigDir = joinRemotePath(ideDataRoot, 'theia');
|
||||
const logFile = joinRemotePath(logsDir, `theia-${options.theiaPort}.log`);
|
||||
const env = {
|
||||
GITZONE_IDE_WORKSPACE: options.workspacePath,
|
||||
GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`,
|
||||
OPENCODE_SERVER_USERNAME: options.opencodeUsername,
|
||||
OPENCODE_SERVER_PASSWORD: options.opencodePassword,
|
||||
NODE_ENV: options.nodeEnv ?? 'production',
|
||||
} satisfies Record<string, string>;
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`mkdir -p ${quoteRemotePath(logsDir)} ${quoteRemotePath(theiaConfigDir)}`,
|
||||
`test -x ${quoteRemotePath(nodePath)} || { printf 'bundled node not executable: %s\n' ${quoteShellArg(nodePath)} >&2; exit 1; }`,
|
||||
`test -f ${quoteRemotePath(joinRemotePath(appDir, 'lib/backend/main.js'))} || { printf 'bundled Theia backend missing: %s\n' ${quoteShellArg(appDir)} >&2; exit 1; }`,
|
||||
`test -d ${quoteRemotePath(options.workspacePath)} || { printf 'workspace path not found: %s\n' ${quoteShellArg(options.workspacePath)} >&2; exit 1; }`,
|
||||
`cd ${quoteRemotePath(options.workspacePath)}`,
|
||||
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
|
||||
`export THEIA_CONFIG_DIR=${quoteRemotePath(theiaConfigDir)}`,
|
||||
...Object.entries(env).map(([key, value]) => {
|
||||
const renderedValue = key === 'GITZONE_IDE_WORKSPACE' ? quoteRemotePath(value) : quoteShellArg(value);
|
||||
return `export ${key}=${renderedValue}`;
|
||||
}),
|
||||
`nohup ${quoteRemotePath(nodePath)} ${quoteRemotePath(joinRemotePath(appDir, 'lib/backend/main.js'))} --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteRemotePath(options.workspacePath)} > ${quoteRemotePath(logFile)} 2>&1 < /dev/null &`,
|
||||
`printf 'runtimeRoot=%s\n' ${quoteShellArg(options.runtimeRoot)}`,
|
||||
`printf 'theiaPort=%s\n' ${options.theiaPort}`,
|
||||
`printf 'opencodePort=%s\n' ${options.opencodePort}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteEphemeralReadinessCommand = (options: IRemoteEphemeralReadinessOptions) => {
|
||||
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
|
||||
const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot;
|
||||
const logFile = joinRemotePath(ideDataRoot, 'logs', `theia-${options.theiaPort}.log`);
|
||||
const waitSeconds = options.waitSeconds ?? 30;
|
||||
const probeScript = `fetch('http://127.0.0.1:${options.theiaPort}/').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))`;
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
|
||||
`for attempt in $(seq 1 ${waitSeconds}); do`,
|
||||
` if ${quoteRemotePath(nodePath)} -e ${quoteShellArg(probeScript)} >/dev/null 2>&1; then`,
|
||||
` printf 'theiaReady=%s\n' ${options.theiaPort}`,
|
||||
' exit 0',
|
||||
' fi',
|
||||
' sleep 1',
|
||||
'done',
|
||||
`printf 'remote Theia did not become ready on port %s\n' ${options.theiaPort} >&2`,
|
||||
`if test -f ${quoteRemotePath(logFile)}; then sed -n '1,200p' ${quoteRemotePath(logFile)} >&2; fi`,
|
||||
'exit 1',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteEphemeralRuntimeCacheCheckCommand = (options: IRemoteEphemeralRuntimeCacheCheckOptions) => {
|
||||
const markerFileName = options.markerFileName ?? remoteEphemeralRuntimeMarkerFileName;
|
||||
const markerPath = joinRemotePath(options.runtimeRoot, markerFileName);
|
||||
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
|
||||
const backendPath = joinRemotePath(options.runtimeRoot, 'applications/remote-theia/lib/backend/main.js');
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`test -f ${quoteRemotePath(markerPath)}`,
|
||||
`test "$(cat ${quoteRemotePath(markerPath)})" = ${quoteShellArg(options.runtimeSha256)}`,
|
||||
`test -x ${quoteRemotePath(nodePath)}`,
|
||||
`test -f ${quoteRemotePath(backendPath)}`,
|
||||
`printf 'runtimeCache=hit\n'`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteHealthCommand = (serverVersion: string, installRoot = defaultInstallRoot) => {
|
||||
const plan = createRemoteServerInstallPlan({
|
||||
serverVersion,
|
||||
@@ -115,8 +217,8 @@ export const createRemoteHealthCommand = (serverVersion: string, installRoot = d
|
||||
});
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`test -f ${quoteShellArg(plan.markerFile)}`,
|
||||
`cat ${quoteShellArg(plan.paths.manifestPath)}`,
|
||||
`test -f ${quoteRemotePath(plan.markerFile)}`,
|
||||
`cat ${quoteRemotePath(plan.paths.manifestPath)}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -128,6 +230,22 @@ export const quoteShellArg = (value: string | number | boolean) => {
|
||||
return `'${stringValue.replace(/'/g, `'"'"'`)}'`;
|
||||
};
|
||||
|
||||
export const quoteRemotePath = (value: string | number | boolean) => {
|
||||
const stringValue = String(value);
|
||||
if (stringValue === '~' || stringValue === '$HOME' || stringValue === '${HOME}') {
|
||||
return '"$HOME"';
|
||||
}
|
||||
|
||||
for (const prefix of ['~/', '$HOME/', '${HOME}/']) {
|
||||
if (stringValue.startsWith(prefix)) {
|
||||
const suffix = stringValue.slice(prefix.length);
|
||||
return suffix ? `"$HOME"/${quoteShellArg(suffix)}` : '"$HOME"';
|
||||
}
|
||||
}
|
||||
|
||||
return quoteShellArg(stringValue);
|
||||
};
|
||||
|
||||
export const joinRemotePath = (...parts: string[]) => {
|
||||
const [first, ...rest] = parts.filter(Boolean);
|
||||
if (!first) {
|
||||
|
||||
+367
-3
@@ -34,6 +34,10 @@ export interface ISshTunnelOptions extends ISshRunOptions {
|
||||
remotePort: number;
|
||||
}
|
||||
|
||||
export interface ISshUploadOptions extends ISshRunOptions {
|
||||
cleanRemote?: boolean;
|
||||
}
|
||||
|
||||
export interface ISshTunnelHandle {
|
||||
readonly target: IIdeSshTarget;
|
||||
readonly localHost: string;
|
||||
@@ -44,6 +48,16 @@ export interface ISshTunnelHandle {
|
||||
dispose(signal?: NodeJS.Signals): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ISshHostSaveInput {
|
||||
alias: string;
|
||||
hostName: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
identityFile?: string;
|
||||
proxyJump?: string;
|
||||
forwardAgent?: boolean;
|
||||
}
|
||||
|
||||
const directiveAliases: Record<string, keyof ISshHostConfig | 'identityFiles'> = {
|
||||
hostname: 'hostName',
|
||||
user: 'user',
|
||||
@@ -105,9 +119,70 @@ export const parseSshConfig = (configText: string): ISshHostConfig[] => {
|
||||
};
|
||||
|
||||
export const readSshConfig = async (filePath = defaultSshConfigPath()) => {
|
||||
return readSshConfigRecursive(filePath, new Set());
|
||||
};
|
||||
|
||||
export const renderSshHostBlock = (input: ISshHostSaveInput) => {
|
||||
const host = normalizeSshHostSaveInput(input);
|
||||
const lines = [`Host ${host.alias}`, ` HostName ${host.hostName}`];
|
||||
if (host.user) {
|
||||
lines.push(` User ${host.user}`);
|
||||
}
|
||||
if (host.port) {
|
||||
lines.push(` Port ${host.port}`);
|
||||
}
|
||||
if (host.identityFile) {
|
||||
lines.push(` IdentityFile ${host.identityFile}`);
|
||||
}
|
||||
if (host.proxyJump) {
|
||||
lines.push(` ProxyJump ${host.proxyJump}`);
|
||||
}
|
||||
if (host.forwardAgent !== undefined) {
|
||||
lines.push(` ForwardAgent ${host.forwardAgent ? 'yes' : 'no'}`);
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
};
|
||||
|
||||
export const saveSshHostConfig = async (
|
||||
input: ISshHostSaveInput,
|
||||
filePath = defaultSshConfigPath(),
|
||||
) => {
|
||||
const host = normalizeSshHostSaveInput(input);
|
||||
const expandedPath = expandHome(filePath);
|
||||
let configText = '';
|
||||
try {
|
||||
configText = await plugins.fs.readFile(expandedPath, 'utf8');
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const nextConfigText = upsertSshHostBlock(configText, host);
|
||||
await plugins.fs.mkdir(plugins.path.dirname(expandedPath), { recursive: true, mode: 0o700 });
|
||||
await plugins.fs.writeFile(expandedPath, nextConfigText, { mode: 0o600 });
|
||||
try {
|
||||
await plugins.fs.chmod(expandedPath, 0o600);
|
||||
} catch {}
|
||||
|
||||
return parseSshConfig(nextConfigText).find((parsedHost) => parsedHost.alias === host.alias)!;
|
||||
};
|
||||
|
||||
const readSshConfigRecursive = async (filePath: string, visitedFiles: Set<string>) => {
|
||||
const expandedPath = expandHome(filePath);
|
||||
if (visitedFiles.has(expandedPath)) {
|
||||
return [];
|
||||
}
|
||||
visitedFiles.add(expandedPath);
|
||||
|
||||
try {
|
||||
const configText = await plugins.fs.readFile(expandHome(filePath), 'utf8');
|
||||
return parseSshConfig(configText);
|
||||
const hosts = parseSshConfig(configText);
|
||||
for (const includePath of await resolveSshIncludePaths(configText, plugins.path.dirname(expandedPath))) {
|
||||
hosts.push(...await readSshConfigRecursive(includePath, visitedFiles));
|
||||
}
|
||||
return hosts;
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'ENOENT') {
|
||||
@@ -145,11 +220,12 @@ export const runSshCommand = async (
|
||||
): Promise<ISshRunResult> => {
|
||||
const executable = options.executable ?? 'ssh';
|
||||
const args = buildSshArgs(target, remoteCommand, options);
|
||||
const env = withSshAgentEnv(options.env ?? process.env);
|
||||
|
||||
return new Promise<ISshRunResult>((resolve, reject) => {
|
||||
const child = plugins.childProcess.spawn(executable, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env ?? process.env,
|
||||
env,
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
@@ -224,7 +300,7 @@ export const startSshTunnel = (
|
||||
];
|
||||
const child = plugins.childProcess.spawn(executable, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env ?? process.env,
|
||||
env: withSshAgentEnv(options.env ?? process.env),
|
||||
shell: false,
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
@@ -259,6 +335,81 @@ export const startSshTunnel = (
|
||||
};
|
||||
};
|
||||
|
||||
export const uploadDirectoryToRemote = async (
|
||||
target: IIdeSshTarget,
|
||||
localDirectory: string,
|
||||
remoteDirectory: string,
|
||||
options: ISshUploadOptions = {},
|
||||
): Promise<ISshRunResult> => {
|
||||
const remoteCommand = [
|
||||
'set -euo pipefail',
|
||||
options.cleanRemote === false ? undefined : `rm -rf ${quoteShellArg(remoteDirectory)}`,
|
||||
`mkdir -p ${quoteShellArg(remoteDirectory)}`,
|
||||
`tar -xzf - -C ${quoteShellArg(remoteDirectory)}`,
|
||||
].filter(Boolean).join('\n');
|
||||
const sshArgs = buildSshArgs(target, remoteCommand, options);
|
||||
const env = withSshAgentEnv(options.env ?? process.env);
|
||||
|
||||
return new Promise<ISshRunResult>((resolve, reject) => {
|
||||
const tar = plugins.childProcess.spawn('tar', ['-czf', '-', '-C', localDirectory, '.'], {
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const ssh = plugins.childProcess.spawn(options.executable ?? 'ssh', sshArgs, {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
shell: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let tarStderr = '';
|
||||
let settled = false;
|
||||
const timeout = options.timeoutMs
|
||||
? setTimeout(() => {
|
||||
tar.kill('SIGTERM');
|
||||
ssh.kill('SIGTERM');
|
||||
finish({ exitCode: 1, stdout: '', stderr: `remote upload timed out after ${options.timeoutMs}ms` });
|
||||
}, options.timeoutMs)
|
||||
: undefined;
|
||||
const finish = (result: ISshRunResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
tar.stdout.pipe(ssh.stdin);
|
||||
tar.stderr.on('data', (chunk: Buffer) => {
|
||||
tarStderr += chunk.toString('utf8');
|
||||
});
|
||||
tar.on('error', reject);
|
||||
ssh.on('error', reject);
|
||||
ssh.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
ssh.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
tar.on('close', (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
ssh.kill('SIGTERM');
|
||||
finish({ exitCode: exitCode ?? 1, stdout: '', stderr: tarStderr || `tar exited with code ${exitCode}` });
|
||||
}
|
||||
});
|
||||
ssh.on('close', (exitCode) => {
|
||||
finish({
|
||||
exitCode: exitCode ?? 1,
|
||||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||
stderr: [tarStderr, Buffer.concat(stderr).toString('utf8')].filter(Boolean).join('\n'),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const probeRemoteHost = async (
|
||||
target: IIdeSshTarget,
|
||||
options: ISshRunOptions = {},
|
||||
@@ -336,12 +487,225 @@ const buildSshOptionArgs = (target: IIdeSshTarget, options: ISshRunOptions = {})
|
||||
}
|
||||
args.push('-o', 'ServerAliveInterval=30');
|
||||
args.push('-o', 'ServerAliveCountMax=3');
|
||||
if (target.hostName && target.hostName !== target.hostAlias) {
|
||||
args.push('-o', `HostName=${target.hostName}`);
|
||||
}
|
||||
if (target.port) {
|
||||
args.push('-p', `${target.port}`);
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const withSshAgentEnv = (env: NodeJS.ProcessEnv) => {
|
||||
const nextEnv = { ...env };
|
||||
if (isSocketPath(nextEnv.SSH_AUTH_SOCK)) {
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
const agentEnvSocket = readAgentEnvSocket();
|
||||
if (isSocketPath(agentEnvSocket)) {
|
||||
nextEnv.SSH_AUTH_SOCK = agentEnvSocket;
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
const tmpSocket = findTmpAgentSocket();
|
||||
if (tmpSocket) {
|
||||
nextEnv.SSH_AUTH_SOCK = tmpSocket;
|
||||
}
|
||||
return nextEnv;
|
||||
};
|
||||
|
||||
const quoteShellArg = (value: string | number | boolean) => {
|
||||
const stringValue = String(value);
|
||||
if (stringValue.length === 0) {
|
||||
return "''";
|
||||
}
|
||||
return `'${stringValue.replace(/'/g, `'"'"'`)}'`;
|
||||
};
|
||||
|
||||
const readAgentEnvSocket = () => {
|
||||
const agentEnvPath = plugins.path.join(plugins.os.homedir(), '.ssh', 'agent.env');
|
||||
try {
|
||||
const agentEnvText = plugins.fsSync.readFileSync(agentEnvPath, 'utf8');
|
||||
return agentEnvText.match(/(?:^|\n)SSH_AUTH_SOCK=([^;\n]+)/)?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const findTmpAgentSocket = () => {
|
||||
const candidates: Array<{ path: string; mtimeMs: number }> = [];
|
||||
try {
|
||||
for (const tmpEntry of plugins.fsSync.readdirSync('/tmp', { withFileTypes: true })) {
|
||||
if (!tmpEntry.isDirectory() || !tmpEntry.name.startsWith('ssh-')) {
|
||||
continue;
|
||||
}
|
||||
const directory = plugins.path.join('/tmp', tmpEntry.name);
|
||||
try {
|
||||
for (const socketEntry of plugins.fsSync.readdirSync(directory, { withFileTypes: true })) {
|
||||
if (!socketEntry.name.startsWith('agent.')) {
|
||||
continue;
|
||||
}
|
||||
const socketPath = plugins.path.join(directory, socketEntry.name);
|
||||
if (isSocketPath(socketPath)) {
|
||||
candidates.push({ path: socketPath, mtimeMs: plugins.fsSync.statSync(socketPath).mtimeMs });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path;
|
||||
};
|
||||
|
||||
const isSocketPath = (filePath: string | undefined) => {
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return plugins.fsSync.statSync(filePath).isSocket();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSshHostSaveInput = (input: ISshHostSaveInput): ISshHostSaveInput => {
|
||||
const host: ISshHostSaveInput = {
|
||||
alias: input.alias.trim(),
|
||||
hostName: input.hostName.trim(),
|
||||
user: input.user?.trim() || undefined,
|
||||
port: input.port,
|
||||
identityFile: input.identityFile?.trim() || undefined,
|
||||
proxyJump: input.proxyJump?.trim() || undefined,
|
||||
forwardAgent: input.forwardAgent,
|
||||
};
|
||||
|
||||
validateSshToken(host.alias, 'Host alias');
|
||||
validateSshToken(host.hostName, 'HostName');
|
||||
if (host.user) {
|
||||
validateSshToken(host.user, 'User');
|
||||
}
|
||||
if (host.identityFile) {
|
||||
validateSshToken(host.identityFile, 'IdentityFile');
|
||||
}
|
||||
if (host.proxyJump) {
|
||||
validateSshToken(host.proxyJump, 'ProxyJump');
|
||||
}
|
||||
if (host.port !== undefined && (!Number.isInteger(host.port) || host.port <= 0 || host.port > 65535)) {
|
||||
throw new Error('Port must be a number from 1 to 65535.');
|
||||
}
|
||||
|
||||
return host;
|
||||
};
|
||||
|
||||
const validateSshToken = (value: string, label: string) => {
|
||||
if (!value) {
|
||||
throw new Error(`${label} is required.`);
|
||||
}
|
||||
if (/\s/.test(value)) {
|
||||
throw new Error(`${label} must not contain whitespace.`);
|
||||
}
|
||||
};
|
||||
|
||||
const upsertSshHostBlock = (configText: string, host: ISshHostSaveInput) => {
|
||||
const blockText = renderSshHostBlock(host).trimEnd();
|
||||
const lines = configText.split(/\r?\n/);
|
||||
const hostBlocks = findSshHostBlocks(lines);
|
||||
const exactBlock = hostBlocks.find((block) => block.patterns.length === 1 && block.patterns[0] === host.alias);
|
||||
if (exactBlock) {
|
||||
lines.splice(exactBlock.start, exactBlock.end - exactBlock.start, ...blockText.split('\n'));
|
||||
return `${trimTrailingBlankLines(lines).join('\n')}\n`;
|
||||
}
|
||||
|
||||
const conflictingBlock = hostBlocks.find((block) => block.patterns.includes(host.alias));
|
||||
if (conflictingBlock) {
|
||||
throw new Error(`Host ${host.alias} is part of a multi-host block. Edit ${defaultSshConfigPath()} manually or choose a new alias.`);
|
||||
}
|
||||
|
||||
const trimmedConfig = configText.trimEnd();
|
||||
if (!trimmedConfig) {
|
||||
return `${blockText}\n`;
|
||||
}
|
||||
return `${trimmedConfig}\n\n${blockText}\n`;
|
||||
};
|
||||
|
||||
const findSshHostBlocks = (lines: string[]) => {
|
||||
const blocks: Array<{ start: number; end: number; patterns: string[] }> = [];
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const tokens = tokenizeSshConfigLine(stripSshComment(lines[index]!).trim());
|
||||
if (tokens[0]?.toLowerCase() !== 'host') {
|
||||
continue;
|
||||
}
|
||||
const nextHostIndex = findNextHostLine(lines, index + 1);
|
||||
blocks.push({
|
||||
start: index,
|
||||
end: nextHostIndex === -1 ? lines.length : nextHostIndex,
|
||||
patterns: tokens.slice(1),
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const findNextHostLine = (lines: string[], startIndex: number) => {
|
||||
for (let index = startIndex; index < lines.length; index++) {
|
||||
const tokens = tokenizeSshConfigLine(stripSshComment(lines[index]!).trim());
|
||||
if (tokens[0]?.toLowerCase() === 'host') {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const trimTrailingBlankLines = (lines: string[]) => {
|
||||
const nextLines = [...lines];
|
||||
while (nextLines.length > 0 && !nextLines[nextLines.length - 1]!.trim()) {
|
||||
nextLines.pop();
|
||||
}
|
||||
return nextLines;
|
||||
};
|
||||
|
||||
const resolveSshIncludePaths = async (configText: string, baseDir: string) => {
|
||||
const includePaths: string[] = [];
|
||||
for (const rawLine of configText.split(/\r?\n/)) {
|
||||
const tokens = tokenizeSshConfigLine(stripSshComment(rawLine).trim());
|
||||
if (tokens[0]?.toLowerCase() !== 'include') {
|
||||
continue;
|
||||
}
|
||||
for (const includePattern of tokens.slice(1)) {
|
||||
includePaths.push(...await expandSshIncludePattern(includePattern, baseDir));
|
||||
}
|
||||
}
|
||||
return includePaths;
|
||||
};
|
||||
|
||||
const expandSshIncludePattern = async (includePattern: string, baseDir: string) => {
|
||||
const expandedPattern = expandHome(includePattern);
|
||||
const absolutePattern = plugins.path.isAbsolute(expandedPattern)
|
||||
? expandedPattern
|
||||
: plugins.path.resolve(baseDir, expandedPattern);
|
||||
const directory = plugins.path.dirname(absolutePattern);
|
||||
const basename = plugins.path.basename(absolutePattern);
|
||||
if (!/[?*]/.test(basename)) {
|
||||
return [absolutePattern];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await plugins.fs.readdir(directory, { withFileTypes: true });
|
||||
const matcher = wildcardToRegExp(basename);
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && matcher.test(entry.name))
|
||||
.map((entry) => plugins.path.join(directory, entry.name))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const wildcardToRegExp = (pattern: string) => {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
|
||||
};
|
||||
|
||||
const stripSshComment = (line: string) => {
|
||||
let quote: string | undefined;
|
||||
for (let index = 0; index < line.length; index++) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as childProcess from 'node:child_process';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as net from 'node:net';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export { childProcess, fs, net, os, path };
|
||||
export { childProcess, fs, fsSync, net, os, path };
|
||||
|
||||
Reference in New Issue
Block a user