Files
devstar-vscode/src/remote-container.ts
yinxue 67cb6ab7f0
Some checks failed
CI/CD Pipeline for DevStar Extension / build (push) Failing after 11s
feature-open-with-vscode (#4)
Co-authored-by: 孟宁 <mengning@mengning.com.cn>
Reviewed-on: #4
Co-authored-by: yinxue <2643126914@qq.com>
Co-committed-by: yinxue <2643126914@qq.com>
2025-12-31 01:52:07 +00:00

484 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// remote-container.ts
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as vscode from 'vscode';
import * as rd from 'readline';
const { NodeSSH } = require('node-ssh');
const { spawn } = require('child_process');
const net = require('net');
import * as utils from './utils';
import User from './user';
import DevstarAPIHandler from './devstar-api';
export default class RemoteContainer {
private user: User;
private sshProcesses?: Map<string, any>;
private portMappings: Map<string, Array<{ containerPort: number, localPort: number, label: string, source: string }>> = new Map();
constructor(user: User) {
this.user = user;
}
public setUser(user: User) {
this.user = user;
}
/**
* 第一次打开远程项目
*/
async firstOpenProject(host: string, hostname: string, port: number, username: string, path: string, context: vscode.ExtensionContext) {
if (vscode.env.remoteName) {
try {
await vscode.commands.executeCommand('workbench.action.terminal.newLocal');
const terminal = vscode.window.terminals[vscode.window.terminals.length - 1];
if (terminal) {
let devstarDomain: string | undefined = context.globalState.get("devstarDomain_" + vscode.env.sessionId);
if (devstarDomain == undefined || devstarDomain == "") {
devstarDomain = undefined;
}
const semver = require('semver');
const powershellVersion = context.globalState.get('powershellVersion');
const powershell_semver_compatible_version = semver.coerce(powershellVersion);
let command = '';
if (devstarDomain === undefined) {
if (powershellVersion === undefined) {
command = `code --new-window && code --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}"`;
} else if (semver.satisfies(powershell_semver_compatible_version, ">=5.1.26100")) {
command = `code --new-window ; code --% --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}"`;
} else {
command = `code --new-window && code --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}"`;
}
} else {
if (powershellVersion === undefined) {
command = `code --new-window && code --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}&devstar_domain=${devstarDomain}"`;
} else if (semver.satisfies(powershell_semver_compatible_version, ">=5.1.26100")) {
command = `code --new-window ; code --% --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}&devstar_domain=${devstarDomain}"`;
} else {
command = `code --new-window && code --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}&devstar_domain=${devstarDomain}"`;
}
}
terminal.sendText(command);
} else {
vscode.window.showErrorMessage('无法创建终端,请检查终端是否可用。');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
vscode.window.showErrorMessage(`远程环境操作失败: ${errorMessage}`);
}
} else {
try {
await this.firstConnect(host, hostname, username, port, path)
.then((res) => {
if (res === 'success') {
this.openRemoteFolder(host, port, username, path, context);
} else {
vscode.window.showErrorMessage('首次连接容器失败,请检查网络和容器状态。');
}
})
.catch(error => {
const errorMessage = error instanceof Error ? error.message : '未知错误';
vscode.window.showErrorMessage(`首次连接容器时发生错误: ${errorMessage}`);
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
vscode.window.showErrorMessage(`打开项目失败: ${errorMessage}`);
}
}
}
/**
* local environment第一次连接其他项目
*/
async firstConnect(host: string, hostname: string, _username: string, port: number, projectPath?: string): Promise<string> {
return new Promise(async (resolve, reject) => {
const ssh = new NodeSSH();
vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: vscode.l10n.t("Installing vscode-server and devstar extension in container"),
cancellable: false
}, async (progress) => {
try {
if (!this.user.existUserPrivateKey() || !this.user.existUserPublicKey()) {
await this.user.createUserSSHKey();
const devstarAPIHandler = new DevstarAPIHandler();
const uploadResult = await devstarAPIHandler.uploadUserPublicKey(this.user);
if (uploadResult !== "ok") {
throw new Error('Upload public key failed.');
}
}
} catch (error) {
reject(error);
return;
}
try {
await ssh.connect({
host: hostname,
username: 'root',
port: port,
privateKeyPath: this.user.getUserPrivateKeyPath(),
readyTimeout: 30000,
onKeyboardInteractive: (
_name: string,
_instructions: string,
_instructionsLang: string,
_prompts: any[],
finish: (responses: string[]) => void
) => {
finish([]);
}
});
progress.report({ message: vscode.l10n.t("Connected! Start installation") });
const vscodeCommitId = await utils.getVsCodeCommitId();
if ("" !== vscodeCommitId) {
const vscodeServerUrl = `https://vscode.download.prss.microsoft.com/dbazure/download/stable/${vscodeCommitId}/vscode-server-linux-x64.tar.gz`;
const installVscodeServerScript = `
mkdir -p ~/.vscode-server/bin/${vscodeCommitId} && \\
if [ "$(ls -A ~/.vscode-server/bin/${vscodeCommitId})" ]; then
echo "VSCode server already exists, installing extension only"
~/.vscode-server/bin/${vscodeCommitId}/bin/code-server --install-extension mengning.devstar
else
echo "Downloading and installing VSCode server"
wget ${vscodeServerUrl} -O vscode-server-linux-x64.tar.gz && \\
mv vscode-server-linux-x64.tar.gz ~/.vscode-server/bin/${vscodeCommitId} && \\
cd ~/.vscode-server/bin/${vscodeCommitId} && \\
tar -xvzf vscode-server-linux-x64.tar.gz --strip-components 1 && \\
rm vscode-server-linux-x64.tar.gz && \\
~/.vscode-server/bin/${vscodeCommitId}/bin/code-server --install-extension mengning.devstar
fi
`;
const installResult = await ssh.execCommand(installVscodeServerScript);
if (installResult.code === 0) {
vscode.window.showInformationMessage(vscode.l10n.t('Installation completed!'));
} else {
throw new Error(`Installation failed with exit code ${installResult.code}: ${installResult.stderr}`);
}
} else {
throw new Error('Failed to get VSCode commit ID');
}
if (projectPath) {
}
await ssh.dispose();
await this.storeProjectSSHInfo(host, hostname, port, 'root');
resolve('success');
} catch (error) {
try {
await ssh.dispose();
} catch (disposeError) {
}
reject(error);
}
});
});
}
/**
* 查找可用的本地端口 - 优先使用相同端口
*/
private async findAvailableLocalPort(containerPort: number): Promise<number> {
if (await this.isPortAvailable(containerPort)) {
return containerPort;
}
for (let offset = 1; offset <= 10; offset++) {
const port1 = containerPort + offset;
if (port1 < 65536 && await this.isPortAvailable(port1)) {
return port1;
}
const port2 = containerPort - offset;
if (port2 > 0 && await this.isPortAvailable(port2)) {
return port2;
}
}
return this.findRandomAvailablePort();
}
/**
* 检查端口是否可用
*/
private async isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.unref();
server.on('error', () => {
resolve(false);
});
server.listen(port, () => {
server.close(() => {
resolve(true);
});
});
});
}
/**
* 查找随机可用端口
*/
private async findRandomAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, () => {
const port = server.address().port;
server.close(() => {
resolve(port);
});
});
});
}
/**
* 创建 SSH 端口转发
*/
private async createSSHPortForward(hostname: string, sshPort: number, containerPort: number, localPort: number): Promise<void> {
return new Promise((resolve, reject) => {
const sshArgs = [
'-N',
'-L',
`${localPort}:localhost:${containerPort}`,
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-p', sshPort.toString(),
'-i', this.user.getUserPrivateKeyPath(),
`root@${hostname}`
];
// 使用 detached 选项让 SSH 进程独立运行,不随父进程退出
const sshProcess = spawn('ssh', sshArgs, {
detached: true, // 让进程在后台独立运行
stdio: 'ignore' // 忽略输入输出,避免进程挂起
});
// 解除父进程对子进程的引用,使其真正独立
sshProcess.unref();
sshProcess.on('error', (error: Error) => {
reject(error);
});
// 由于使用了 stdio: 'ignore',这些事件监听器不再需要
// sshProcess.stdout.on('data', (data: Buffer) => {
// console.log(`[SSH stdout] ${data.toString()}`);
// });
// sshProcess.stderr.on('data', (data: Buffer) => {
// console.error(`[SSH stderr] ${data.toString()}`);
// });
// 注意:由于进程已 detached 和 unref不再需要保存到 sshProcesses Map
// 因为我们无法也不需要控制这些独立进程的生命周期
// 等待 SSH 连接建立
setTimeout(() => {
resolve();
}, 2000);
});
}
/**
* 显示端口映射汇总信息
*/
private showPortMappingsSummary(portMappings: Array<{ containerPort: number, localPort: number, label: string, source: string }>): void {
let message = `🎯 已建立 ${portMappings.length} 个端口映射:\n\n`;
portMappings.forEach(mapping => {
const samePort = mapping.containerPort === mapping.localPort ? " ✅" : " 🔄";
message += `${mapping.label}\n`;
message += ` 容器端口: ${mapping.containerPort} → 本地端口: ${mapping.localPort}${samePort}\n`;
message += ` 访问地址: http://localhost:${mapping.localPort}\n`;
message += ` 配置来源: ${mapping.source}\n\n`;
});
vscode.window.showInformationMessage(message, '复制映射信息', '在浏览器中打开', '查看详细信息')
.then(selection => {
if (selection === '复制映射信息') {
const copyText = portMappings.map(m =>
`${m.label}: http://localhost:${m.localPort} (容器端口: ${m.containerPort})`
).join('\n');
vscode.env.clipboard.writeText(copyText);
vscode.window.showInformationMessage('端口映射信息已复制到剪贴板');
} else if (selection === '在浏览器中打开' && portMappings.length > 0) {
vscode.env.openExternal(vscode.Uri.parse(`http://localhost:${portMappings[0].localPort}`));
} else if (selection === '查看详细信息') {
}
});
}
/**
* 本地环境保存项目的ssh连接信息
*/
async storeProjectSSHInfo(host: string, hostname: string, port: number, username: string): Promise<void> {
const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
let canAppendSSHConfig = true;
if (fs.existsSync(sshConfigPath)) {
const reader = rd.createInterface(fs.createReadStream(sshConfigPath));
for await (const line of reader) {
if (line.includes(`Host ${host}`)) {
canAppendSSHConfig = false;
break;
}
}
}
if (canAppendSSHConfig) {
const privateKeyPath = this.user.getUserPrivateKeyPath();
const newSShConfigContent =
`\nHost ${host}\n HostName ${hostname}\n Port ${port}\n User ${username}\n PreferredAuthentications publickey\n IdentityFile ${privateKeyPath}\n `;
try {
fs.writeFileSync(sshConfigPath, newSShConfigContent, { encoding: 'utf8', flag: 'a' });
} catch (error) {
throw error;
}
}
}
/**
* local env
*/
async openRemoteFolder(host: string, port: number, _username: string, path: string, context: vscode.ExtensionContext): Promise<void> {
try {
const sshConfig = await this.getSSHConfig(host);
if (sshConfig) {
try {
// 调用 setupPortForwardingFromGlobalState 方法
await this.setupPortForwardingFromGlobalState(sshConfig.hostname, port, context);
} catch (portError) {
vscode.window.showWarningMessage('端口映射设置失败,但容器连接已建立');
}
}
let terminal = vscode.window.activeTerminal || vscode.window.createTerminal(`Ext Terminal`);
terminal.show(true);
const command = `code --remote ssh-remote+root@${host}:${port} ${path} --reuse-window`;
terminal.sendText(command);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
vscode.window.showErrorMessage(`打开远程文件夹失败: ${errorMessage}`);
}
}
/**
* 从 SSH config 获取主机配置
*/
private async getSSHConfig(host: string): Promise<{ hostname: string; port: number } | null> {
const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
if (!fs.existsSync(sshConfigPath)) {
return null;
}
const configContent = fs.readFileSync(sshConfigPath, 'utf8');
const lines = configContent.split('\n');
let currentHost = '';
let hostname = '';
let port = 22;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('Host ')) {
currentHost = trimmed.substring(5).trim();
} else if (currentHost === host) {
if (trimmed.startsWith('HostName ')) {
hostname = trimmed.substring(9).trim();
} else if (trimmed.startsWith('Port ')) {
port = parseInt(trimmed.substring(5).trim());
}
}
if (hostname && currentHost === host) {
return { hostname, port };
}
}
return null;
}
/**
* 从 globalState 获取 forwardPorts 并建立端口映射
*/
public async setupPortForwardingFromGlobalState(hostname: string, port: number, context: vscode.ExtensionContext): Promise<void> {
// 从 globalState 获取 forwardPorts 参数
const forwardPorts: number[] | undefined = context.globalState.get('forwardPorts');
if (forwardPorts && forwardPorts.length > 0) {
const portMappings: Array<{ containerPort: number, localPort: number, label: string, source: string }> = [];
for (const containerPort of forwardPorts) {
const localPort = await this.findAvailableLocalPort(containerPort);
try {
await this.createSSHPortForward(hostname, port, containerPort, localPort);
portMappings.push({
containerPort,
localPort,
label: `Port ${containerPort}`,
source: 'globalState forwardPorts'
});
} catch (error) {
console.error(`映射容器端口 ${containerPort} 到本地端口 ${localPort} 失败:`, error);
}
}
const mappingKey = `${hostname}:${port}`;
this.portMappings.set(mappingKey, portMappings);
if (portMappings.length > 0) {
this.showPortMappingsSummary(portMappings);
}
// 使用完毕后立即清除 globalState 中的 forwardPorts避免影响下一个项目
console.log('端口映射完成,清除 globalState 中的 forwardPorts');
context.globalState.update('forwardPorts', undefined);
} else {
console.log('未找到 forwardPorts 参数,跳过端口映射设置。');
}
}
}
/**
* 打开项目(无须插件登录)
*/
export async function openProjectWithoutLogging(hostname: string, port: number, username: string, path: string): Promise<void> {
const command = `code --remote ssh-remote+${username}@${hostname}:${port} ${path} --reuse-window`;
try {
let terminal = vscode.window.activeTerminal || vscode.window.createTerminal(`Ext Terminal`);
terminal.show(true);
terminal.sendText(command);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
vscode.window.showErrorMessage(`无登录打开项目失败: ${errorMessage}`);
}
}