diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 056c8c4..2a893ec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,3 +34,22 @@ jobs: run: tool/yarn tsc --noEmit - name: Prettier run: tool/yarn prettier --check . + - name: Set up SSH + uses: webfactory/ssh-agent@v0.5.3 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + - name: Test in SSH environment + run: | + ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 'cd /path/to/extension && tool/yarn test' + - name: Set up Dev Container + uses: devcontainers/ci@v0 + with: + image: mcr.microsoft.com/vscode/devcontainers/base:0-focal + - name: Test in Dev Container + run: | + docker exec -w /path/to/extension devcontainer tool/yarn test + - name: Set up GitHub Codespaces + uses: actions/checkout@v3 + - name: Test in GitHub Codespaces + run: | + codespaces exec -w /path/to/extension tool/yarn test diff --git a/package.json b/package.json index 28098e5..aa841be 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ ], "extensionKind": [ "ui", - "workspace" + "workspace", + "remote" ], "main": "./dist/extension.js", "contributes": { diff --git a/src/config-manager.test.ts b/src/config-manager.test.ts index b73c62e..f9d366a 100644 --- a/src/config-manager.test.ts +++ b/src/config-manager.test.ts @@ -42,3 +42,34 @@ test('set persists config to disk', () => { const f = fs.readFileSync(configPath, 'utf8'); expect(JSON.parse(f)).toEqual({ hosts }); }); + +test('withContext initializes remote-specific configurations if running in a remote context', () => { + vi.spyOn(vscode.env, 'remoteName', 'get').mockReturnValue('ssh-remote'); + const cm = ConfigManager.withGlobalStorageUri(globalStorageUri); + expect(cm.config.remoteHost).toBe('ssh-remote'); +}); + +test('setForHost handles remote-specific configurations', () => { + const cm = new ConfigManager(configPath); + const hostname = 'remote-host'; + const remoteConfig = { + remoteHost: 'remote-host', + remotePort: 22, + remoteUser: 'remote-user', + }; + + cm.setForHost(hostname, 'remoteHost', remoteConfig.remoteHost); + cm.setForHost(hostname, 'remotePort', remoteConfig.remotePort); + cm.setForHost(hostname, 'remoteUser', remoteConfig.remoteUser); + + expect(cm.config.hosts?.[hostname]?.remoteHost).toBe(remoteConfig.remoteHost); + expect(cm.config.hosts?.[hostname]?.remotePort).toBe(remoteConfig.remotePort); + expect(cm.config.hosts?.[hostname]?.remoteUser).toBe(remoteConfig.remoteUser); + + const f = fs.readFileSync(configPath, 'utf8'); + expect(JSON.parse(f)).toEqual({ + hosts: { + [hostname]: remoteConfig, + }, + }); +}); diff --git a/src/config-manager.ts b/src/config-manager.ts index bd9d8f3..be674c1 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -7,6 +7,9 @@ interface Host { rootDir: string; persistToSSHConfig?: boolean; differentUserFromSSHConfig?: boolean; + remoteHost?: string; + remotePort?: number; + remoteUser?: string; } interface Config { @@ -33,7 +36,15 @@ export class ConfigManager { fs.mkdirSync(globalStoragePath); } - return new ConfigManager(path.join(globalStoragePath, 'config.json')); + const configManager = new ConfigManager(path.join(globalStoragePath, 'config.json')); + + // Detect if the extension is running in a remote context + const isRemote = !!vscode.env.remoteName; + if (isRemote) { + configManager.set('remoteHost', vscode.env.remoteName); + } + + return configManager; } set(key: K, value: Config[K]) { diff --git a/src/extension.ts b/src/extension.ts index ac1daee..865bc4a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,6 +28,10 @@ export async function activate(context: vscode.ExtensionContext) { const configManager = ConfigManager.withGlobalStorageUri(context.globalStorageUri); + // Detect if the extension is running in a remote context + const isRemote = !!vscode.env.remoteName; + vscode.commands.executeCommand('setContext', 'tailscale.isRemote', isRemote); + // walkthrough completion tailscaleInstance.serveStatus().then((status) => { // assume if we have any BackendState we are installed @@ -240,6 +244,55 @@ export async function activate(context: vscode.ExtensionContext) { }, 500); }) ); + + // Update commands and UI elements to handle remote-specific scenarios + if (isRemote) { + context.subscriptions.push( + vscode.commands.registerCommand('tailscale.node.openTerminal', async (node: PeerRoot | FileExplorer) => { + const { addr, path } = extractAddrAndPath(node); + + if (!addr) { + return; + } + + const t = vscode.window.createTerminal(addr); + t.sendText(`ssh ${getUsername(configManager, addr)}@${addr}`); + + if (path) { + t.sendText(`cd ${path}`); + } + + t.show(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('tailscale.node.openRemoteCode', async (node: PeerRoot | FileExplorer) => { + const { addr, path } = extractAddrAndPath(node); + + if (addr && configManager.config.hosts?.[addr]?.persistToSSHConfig !== false) { + await syncSSHConfig(addr, configManager); + } + + if (node instanceof PeerRoot && addr) { + vscode.commands.executeCommand('vscode.newWindow', { + remoteAuthority: `ssh-remote+${addr}`, + reuseWindow: false, + }); + } else if (node instanceof FileExplorer && addr) { + vscode.commands.executeCommand( + 'vscode.openFolder', + vscode.Uri.from({ + scheme: 'vscode-remote', + authority: `ssh-remote+${addr}`, + path, + }), + { forceNewWindow: true } + ); + } + }) + ); + } } export function deactivate() {