diff --git a/packages/teleterm/README.md b/packages/teleterm/README.md index 9e5823b14..3acf453e2 100644 --- a/packages/teleterm/README.md +++ b/packages/teleterm/README.md @@ -145,7 +145,7 @@ node-pty](https://github.com/microsoft/node-pty#dependencies). ### Linux -To create an RPM package for an arm64 target you need to provide `USE_SYSTEM_FPM=1` env var. +To create arm64 deb and RPM packages you need to provide `USE_SYSTEM_FPM=1` env var. ### macOS diff --git a/packages/teleterm/build_resources/README.md b/packages/teleterm/build_resources/README.md new file mode 100644 index 000000000..c90ca5e61 --- /dev/null +++ b/packages/teleterm/build_resources/README.md @@ -0,0 +1,7 @@ +This is the directory we use as the `buildResources` dir for electron-builder. + +By default, electron-builder uses the `build` dir at the project root. However, we already use that +directory for webpack output. + +If you see a path in electron-builder docs referring to `build`, you can assume that they meant the +`buildResources` directory. diff --git a/packages/teleterm/assets/icon-linux/512x512.png b/packages/teleterm/build_resources/icon-linux/512x512.png similarity index 100% rename from packages/teleterm/assets/icon-linux/512x512.png rename to packages/teleterm/build_resources/icon-linux/512x512.png diff --git a/packages/teleterm/assets/icon-mac.png b/packages/teleterm/build_resources/icon-mac.png similarity index 100% rename from packages/teleterm/assets/icon-mac.png rename to packages/teleterm/build_resources/icon-mac.png diff --git a/packages/teleterm/assets/icon-win.ico b/packages/teleterm/build_resources/icon-win.ico similarity index 100% rename from packages/teleterm/assets/icon-win.ico rename to packages/teleterm/build_resources/icon-win.ico diff --git a/packages/teleterm/build_resources/installer.nsh b/packages/teleterm/build_resources/installer.nsh new file mode 100644 index 000000000..1df15aef6 --- /dev/null +++ b/packages/teleterm/build_resources/installer.nsh @@ -0,0 +1,24 @@ +# https://github.com/electron-userland/electron-builder/blob/v24.0.0-alpha.5/docs/configuration/nsis.md#custom-nsis-script + +# electron-builder adds `BUILD_RESOURCES_DIR\x86-unicode` as a plugin dir. +# But that dir name isn't very descriptive, so we add a custom plugin dir. +!addplugindir "${BUILD_RESOURCES_DIR}\nsis-plugins" + +# The EnVar plugin is recommended for env var modification as EnvVarUpdate doesn't handle long +# strings very well. +# https://nsis.sourceforge.io/Environmental_Variables:_append,_prepend,_and_remove_entries +# https://nsis.sourceforge.io/EnVar_plug-in + +!macro customInstall + # Make EnVar define user env vars instead of system env vars. + EnVar::SetHKCU + EnVar::AddValue "Path" $INSTDIR\resources\bin +!macroend + +!macro customUnInstall + EnVar::SetHKCU + # Inside the uninstaller, $INSTDIR is the directory where the uninstaller lies. + # Fortunately, electron-builder puts the uninstaller directly into the actual installation dir. + # https://nsis.sourceforge.io/Docs/Chapter4.html#varother + EnVar::DeleteValue "Path" $INSTDIR\resources\bin +!macroend diff --git a/packages/teleterm/build_resources/linux/README.md b/packages/teleterm/build_resources/linux/README.md new file mode 100644 index 000000000..d86f3cd2c --- /dev/null +++ b/packages/teleterm/build_resources/linux/README.md @@ -0,0 +1,25 @@ +## Differences between deb & RPM scripts + +During an upgrade of a deb package from one version to another, the following steps happen: + +1. The new version is unpacked, with the new files overwriting the old files. +2. The after-remove of the old version is called. +3. The old files are removed. +4. The after-install of the new version is called. + +See: + +- [Debian Policy Manual: Upgrading a package](https://www.debian.org/doc/debian-policy/ap-flowcharts.html#id5). +- [Debian Policy Manual: Details of unpack phase of installation or + upgrade](https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html#details-of-unpack-phase-of-installation-or-upgrade) + +However, when you upgrade an RPM package, the scriptlets are called in a reverse order: + +1. The new version is unpacked, with the new files overwriting the old files. +2. The after-install of the new version is called. +3. The old files are removed. +4. The after-remove of the old version is called. + +See [Fedora Docs: Scriptlets - Ordering](https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/#ordering). + +This has direct consequences when attempting to use the same scripts for both targets. diff --git a/packages/teleterm/build_resources/linux/after-install.tpl b/packages/teleterm/build_resources/linux/after-install.tpl new file mode 100644 index 000000000..9bcd38e9a --- /dev/null +++ b/packages/teleterm/build_resources/linux/after-install.tpl @@ -0,0 +1,52 @@ +#!/bin/bash +set -eu + +### +# Default after-install.tpl copied from electron-builder. +# https://github.com/electron-userland/electron-builder/blob/v24.0.0-alpha.5/packages/app-builder-lib/templates/linux/after-install.tpl +### + +# SUID chrome-sandbox for Electron 5+ +chmod 4755 "/opt/${sanitizedProductName}/chrome-sandbox" || true + +update-mime-database /usr/share/mime || true +update-desktop-database /usr/share/applications || true + +### +# Custom after-install.tpl script. +### + +APP="/opt/${sanitizedProductName}" +BIN=/usr/local/bin +TSH_SYMLINK_SOURCE=$APP/resources/bin/tsh +TSH_SYMLINK_TARGET=$BIN/tsh + +# Create $BIN if it doesn't exist. +[ ! -d "$BIN" ] && mkdir -p "$BIN" + +# Link to the Electron app binary. +ln -sf "$APP/${executable}" "$BIN/${executable}" + +# Link to the bundled tsh if the symlink doesn't exist already. Otherwise echo a message unless the +# link points to teleport-connect's tsh already. +# Does TSH_SYMLINK_TARGET not exist? +if [ ! -e "$TSH_SYMLINK_TARGET" ]; then + ln -s "$TSH_SYMLINK_SOURCE" "$TSH_SYMLINK_TARGET" +else + message="${executable}: Skipping symlinking $TSH_SYMLINK_TARGET to $TSH_SYMLINK_SOURCE" + + # Is TSH_SYMLINK_TARGET a symlink? + if [ -L "$TSH_SYMLINK_TARGET" ]; then + # Does TSH_SYMLINK_TARGET point at something else than TSH_SYMLINK_SOURCE? + # If TSH_SYMLINK_TARGET exists and it points at TSH_SYMLINK_SOURCE already, don't do anything. + if [ ! "$TSH_SYMLINK_TARGET" -ef "$TSH_SYMLINK_SOURCE" ]; then + message+=" because the symlink already exists and it points to $(realpath $TSH_SYMLINK_TARGET)." + echo "$message" + fi + else + message+=" because $TSH_SYMLINK_TARGET already exists and it isn't a symlink." + echo "$message" + fi +fi + +# vim: syntax=sh diff --git a/packages/teleterm/build_resources/linux/after-remove.tpl b/packages/teleterm/build_resources/linux/after-remove.tpl new file mode 100644 index 000000000..9656ea68c --- /dev/null +++ b/packages/teleterm/build_resources/linux/after-remove.tpl @@ -0,0 +1,44 @@ +#!/bin/bash +set -eu + +# Do not touch symlinks if the package is being upgraded. +# +# Why? +# +# During an upgrade, RPM packages call after-install of new version first followed by after-remove +# of the old version. deb packages do this in reverse order. See README.md in this directory for +# more details. +# +# So, for RPM packages we should not remove the symlinks if the package is being upgraded, otherwise +# the user would end up with no symlinks after an upgrade. +# +# How? +# +# Both deb and RPM pass arguments to the scripts. rpm passes the number of packages of the given +# name which will be left on the system when the action completes. deb passes "upgrade" during an +# upgrade. We can check those args to determine if the package is being upgraded or removed. +# +# https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html#details-of-unpack-phase-of-installation-or-upgrade +# https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/#_syntax +# +# Is the first argument "upgrade" or "1"? +if [ "$1" = "upgrade" ] || [ "$1" = "1" ]; then + echo "${executable}: Upgrade detected, skipping symlink operations" + exit 0 +fi + +BIN=/usr/local/bin +TSH_SYMLINK_TARGET=$BIN/tsh + +# Remove the link to the Electron app binary. +rm -f "$BIN/${executable}" + +# At this point, the app has already been removed from disk. If TSH_SYMLINK_TARGET used to point at +# tsh bundled with the teleport-connect package, it is a broken symlink now. +# +# Is TSH_SYMLINK_TARGET a link that points at a file which doesn't exist? +if [ -L "$TSH_SYMLINK_TARGET" ] && [ ! -e "$TSH_SYMLINK_TARGET" ]; then + rm -f "$TSH_SYMLINK_TARGET" +fi + +# vim: syntax=sh diff --git a/packages/teleterm/build_resources/nsis-plugins/EnVar.dll b/packages/teleterm/build_resources/nsis-plugins/EnVar.dll new file mode 100644 index 000000000..964e3427d Binary files /dev/null and b/packages/teleterm/build_resources/nsis-plugins/EnVar.dll differ diff --git a/packages/teleterm/build_resources/nsis-plugins/README.md b/packages/teleterm/build_resources/nsis-plugins/README.md new file mode 100644 index 000000000..4f25daeac --- /dev/null +++ b/packages/teleterm/build_resources/nsis-plugins/README.md @@ -0,0 +1,6 @@ +By default, electron-builder adds `${buildResources}\x86-unicode` as the plugin dir. But that name +is not really that descriptive, so we put the plugins under nsis-plugins. + +When you download a plugin, its `Plugins` folder is likely to contain .dlls for different +architectures and encodings such as amd64-unicode, x86-ansi, x86-unicode. You should use the .dlls +from the `x86-unicode` dir. diff --git a/packages/teleterm/electron-builder-config.js b/packages/teleterm/electron-builder-config.js index d199c0dd8..e06eef635 100644 --- a/packages/teleterm/electron-builder-config.js +++ b/packages/teleterm/electron-builder-config.js @@ -45,7 +45,7 @@ module.exports = { gatekeeperAssess: false, // If CONNECT_TSH_APP_PATH is provided, we assume that tsh.app is already signed. signIgnore: env.CONNECT_TSH_APP_PATH && ['tsh.app'], - icon: 'assets/icon-mac.png', + icon: 'build_resources/icon-mac.png', // On macOS, helper apps (such as tsh.app) should be under Contents/MacOS, hence using // `extraFiles` instead of `extraResources`. // https://developer.apple.com/documentation/bundleresources/placing_content_in_a_bundle @@ -83,7 +83,7 @@ module.exports = { win: { target: ['nsis'], artifactName: '${productName} Setup-${version}.${ext}', - icon: 'assets/icon-win.ico', + icon: 'build_resources/icon-win.ico', extraResources: [ env.CONNECT_TSH_BIN_PATH && { from: env.CONNECT_TSH_BIN_PATH, @@ -93,6 +93,8 @@ module.exports = { }, rpm: { artifactName: '${name}-${version}.${arch}.${ext}', + afterInstall: 'build_resources/linux/after-install.tpl', + afterRemove: 'build_resources/linux/after-remove.tpl', // --rpm-rpmbuild-define "_build_id_links none" fixes the problem with not being able to install // Connect's rpm next to other Electron apps. // https://github.com/gravitational/teleport/issues/18859 @@ -100,12 +102,14 @@ module.exports = { }, deb: { artifactName: '${name}_${version}_${arch}.${ext}', + afterInstall: 'build_resources/linux/after-install.tpl', + afterRemove: 'build_resources/linux/after-remove.tpl', }, linux: { target: ['tar.gz', 'rpm', 'deb'], - artifactName: '${name}-${version}-${arch}.${ext}', //tar.gz + artifactName: '${name}-${version}-${arch}.${ext}', // tar.gz category: 'Development', - icon: 'assets/icon-linux', + icon: 'build_resources/icon-linux', extraResources: [ env.CONNECT_TSH_BIN_PATH && { from: env.CONNECT_TSH_BIN_PATH, @@ -114,8 +118,7 @@ module.exports = { ].filter(Boolean), }, directories: { - buildResources: 'assets', + buildResources: 'build_resources', output: 'build/release', }, - extraResources: ['./assets/**'], }; diff --git a/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/packages/teleterm/src/mainProcess/fixtures/mocks.ts index 450356c7b..cab95c27f 100644 --- a/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -5,50 +5,30 @@ import { createMockFileStorage } from 'teleterm/services/fileStorage/fixtures/mo // Importing electron breaks the fixtures if that's done from within storybook. import { createConfigService } from 'teleterm/services/config/configService'; -const platform = 'darwin'; - export class MockMainProcessClient implements MainProcessClient { + configService: ReturnType; + + constructor(private runtimeSettings: Partial = {}) { + this.configService = createConfigService( + createMockFileStorage(), + this.getRuntimeSettings().platform + ); + } getRuntimeSettings(): RuntimeSettings { - return { - platform, - dev: true, - userDataDir: '', - binDir: '', - certsDir: '', - kubeConfigsDir: '', - defaultShell: '', - tshd: { - insecure: true, - requestedNetworkAddress: '', - binaryPath: '', - homeDir: '', - flags: [], - }, - sharedProcess: { - requestedNetworkAddress: '', - }, - tshdEvents: { - requestedNetworkAddress: '', - }, - installationId: '123e4567-e89b-12d3-a456-426614174000', - }; + return { ...defaultRuntimeSettings, ...this.runtimeSettings }; } getResolvedChildProcessAddresses = () => Promise.resolve({ tsh: '', shared: '' }); openTerminalContextMenu() {} - openClusterContextMenu() {} - openTabContextMenu() {} showFileSaveDialog() { return Promise.resolve({ canceled: false, filePath: '' }); } - configService = createConfigService(createMockFileStorage(), platform); - fileStorage = createMockFileStorage(); removeKubeConfig(): Promise { @@ -56,4 +36,35 @@ export class MockMainProcessClient implements MainProcessClient { } forceFocusWindow() {} + + async symlinkTshMacOs() { + return true; + } + async removeTshSymlinkMacOs() { + return true; + } } + +const defaultRuntimeSettings = { + platform: 'darwin' as const, + dev: true, + userDataDir: '', + binDir: '', + certsDir: '', + kubeConfigsDir: '', + defaultShell: '', + tshd: { + insecure: true, + requestedNetworkAddress: '', + binaryPath: '', + homeDir: '', + flags: [], + }, + sharedProcess: { + requestedNetworkAddress: '', + }, + tshdEvents: { + requestedNetworkAddress: '', + }, + installationId: '123e4567-e89b-12d3-a456-426614174000', +}; diff --git a/packages/teleterm/src/mainProcess/mainProcess.ts b/packages/teleterm/src/mainProcess/mainProcess.ts index b8cf21bdf..ff98342f3 100644 --- a/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/packages/teleterm/src/mainProcess/mainProcess.ts @@ -1,8 +1,8 @@ -import { ChildProcess, fork, spawn } from 'child_process'; +import { ChildProcess, fork, spawn, exec } from 'child_process'; import path from 'path'; - import fs from 'fs/promises'; +import { promisify } from 'util'; import { app, dialog, @@ -203,6 +203,52 @@ export default class MainProcess { this.windowsManager.forceFocusWindow(); }); + // Used in the `tsh install` command on macOS to make the bundled tsh available in PATH. + // Returns true if tsh got successfully installed, false if the user closed the osascript + // prompt. Throws an error when osascript fails. + ipcMain.handle('main-process-symlink-tsh-macos', async () => { + const source = this.settings.tshd.binaryPath; + const target = '/usr/local/bin/tsh'; + const prompt = + 'Teleport Connect wants to create a symlink for tsh in /usr/local/bin.'; + const command = `osascript -e "do shell script \\"mkdir -p /usr/local/bin && ln -sf '${source}' '${target}'\\" with prompt \\"${prompt}\\" with administrator privileges"`; + + try { + await promisify(exec)(command); + this.logger.info(`Created the symlink to ${source} under ${target}`); + return true; + } catch (error) { + // Ignore the error if the user canceled the prompt. + // https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/reference/ASLR_error_codes.html#//apple_ref/doc/uid/TP40000983-CH220-SW2 + if (error instanceof Error && error.message.includes('-128')) { + return false; + } + this.logger.error(error); + throw error; + } + }); + + ipcMain.handle('main-process-remove-tsh-symlink-macos', async () => { + const target = '/usr/local/bin/tsh'; + const prompt = + 'Teleport Connect wants to remove a symlink for tsh from /usr/local/bin.'; + const command = `osascript -e "do shell script \\"rm '${target}'\\" with prompt \\"${prompt}\\" with administrator privileges"`; + + try { + await promisify(exec)(command); + this.logger.info(`Removed the symlink under ${target}`); + return true; + } catch (error) { + // Ignore the error if the user canceled the prompt. + // https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/reference/ASLR_error_codes.html#//apple_ref/doc/uid/TP40000983-CH220-SW2 + if (error instanceof Error && error.message.includes('-128')) { + return false; + } + this.logger.error(error); + throw error; + } + }); + subscribeToTerminalContextMenuEvent(); subscribeToTabContextMenuEvent(); subscribeToConfigServiceEvents(this.configService); diff --git a/packages/teleterm/src/mainProcess/mainProcessClient.ts b/packages/teleterm/src/mainProcess/mainProcessClient.ts index 200a557bc..763af92fb 100644 --- a/packages/teleterm/src/mainProcess/mainProcessClient.ts +++ b/packages/teleterm/src/mainProcess/mainProcessClient.ts @@ -31,5 +31,11 @@ export default function createMainProcessClient(): MainProcessClient { forceFocusWindow() { return ipcRenderer.invoke('main-process-force-focus-window'); }, + symlinkTshMacOs() { + return ipcRenderer.invoke('main-process-symlink-tsh-macos'); + }, + removeTshSymlinkMacOs() { + return ipcRenderer.invoke('main-process-remove-tsh-symlink-macos'); + }, }; } diff --git a/packages/teleterm/src/mainProcess/types.ts b/packages/teleterm/src/mainProcess/types.ts index 9816427c2..e06916c8d 100644 --- a/packages/teleterm/src/mainProcess/types.ts +++ b/packages/teleterm/src/mainProcess/types.ts @@ -43,6 +43,16 @@ export type MainProcessClient = { isDirectory?: boolean; }): Promise; forceFocusWindow(): void; + /** + * The promise returns true if tsh got successfully symlinked, false if the user closed the + * osascript prompt. The promise gets rejected if osascript encountered an error. + */ + symlinkTshMacOs(): Promise; + /** + * The promise returns true if the tsh symlink got removed, false if the user closed the osascript + * prompt. The promise gets rejected if osascript encountered an error. + */ + removeTshSymlinkMacOs(): Promise; }; export type ChildProcessAddresses = { diff --git a/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts b/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts index 443c3db0c..55f80d474 100644 --- a/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts +++ b/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts @@ -128,6 +128,11 @@ function prependBinDirToPath( env: typeof process.env, settings: RuntimeSettings ): void { + // On Windows, if settings.binDir is already in Path, this function will simply put in the front, + // guaranteeing that any shell session started from within Connect will use the bundled tsh. + // + // Windows seems to construct Path by first taking the system Path env var and adding to it the + // user Path env var. const pathName = settings.platform === 'win32' ? 'Path' : 'PATH'; env[pathName] = [settings.binDir, env[pathName]] .map(path => path?.trim()) diff --git a/packages/teleterm/src/ui/QuickInput/QuickInput.story.tsx b/packages/teleterm/src/ui/QuickInput/QuickInput.story.tsx index 6310fee19..c2f5fa9c8 100644 --- a/packages/teleterm/src/ui/QuickInput/QuickInput.story.tsx +++ b/packages/teleterm/src/ui/QuickInput/QuickInput.story.tsx @@ -16,12 +16,21 @@ import React from 'react'; +import Flex from 'design/Flex'; + import AppContextProvider from 'teleterm/ui/appContextProvider'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; - -import { getEmptyPendingAccessRequest } from '../services/workspacesService/accessRequestsService'; +import { getEmptyPendingAccessRequest } from 'teleterm/ui/services/workspacesService/accessRequestsService'; +import * as types from 'teleterm/services/tshd/types'; +import { + SuggestionCmd, + SuggestionDatabase, + SuggestionServer, + SuggestionSshLogin, +} from 'teleterm/ui/services/quickInput'; import QuickInput from './QuickInput'; +import QuickInputList from './QuickInputList'; export default { title: 'Teleterm/QuickInput', @@ -46,27 +55,25 @@ export const Story = () => { }; appContext.clustersService.getClusters = () => { - return [ - { - uri: '/clusters/localhost', - name: 'Test', - leaf: false, - connected: true, - proxyHost: 'localhost:3080', - authClusterId: '73c4746b-d956-4f16-9848-4e3469f70762', - loggedInUser: { - activeRequestsList: [], - name: 'admin', - acl: {}, - sshLoginsList: [], - rolesList: [], - requestableRolesList: [], - suggestedReviewersList: [], - }, - }, - ]; + return [cluster]; }; + appContext.clustersService.setState(draftState => { + draftState.clusters = new Map([[cluster.uri, cluster]]); + }); + + appContext.resourcesService.fetchServers = async () => ({ + agentsList: servers, + totalCount: 3, + startKey: '', + }); + + appContext.resourcesService.fetchDatabases = async () => ({ + agentsList: databases, + totalCount: 3, + startKey: '', + }); + return (
{ ); }; + +export const Suggestions = () => { + const commandSuggestions: SuggestionCmd[] = [ + { + kind: 'suggestion.cmd', + token: '', + data: { + displayName: 'tsh foo', + description: 'Nulla convallis lorem ut ipsum maximus venenatis.', + }, + }, + { + kind: 'suggestion.cmd', + token: '', + data: { + displayName: 'tsh bar', + description: 'Vivamus id nulla sed neque efficitur ornare nec in diam.', + }, + }, + { + kind: 'suggestion.cmd', + token: '', + data: { + displayName: 'tsh quux foo', + description: + 'Sed porta nibh eget lacus suscipit vehicula. Curabitur eget sapien in lacus blandit pretium.', + }, + }, + { + kind: 'suggestion.cmd', + token: '', + data: { + displayName: 'tsh baz quux', + description: 'Etiam cursus magna at feugiat ornare.', + }, + }, + ]; + + const loginSuggestions: SuggestionSshLogin[] = + cluster.loggedInUser.sshLoginsList.map(login => ({ + kind: 'suggestion.ssh-login', + token: '', + appendToToken: '', + data: login, + })); + + const serverSuggestions: SuggestionServer[] = servers.map(server => ({ + kind: 'suggestion.server', + token: '', + data: server, + })); + + const dbSuggestions: SuggestionDatabase[] = databases.map(db => ({ + kind: 'suggestion.database', + token: '', + data: db, + })); + + return ( + + + + + + + ); +}; + +const defaultWidth = 200; +const defaultHeight = 200; + +const QuickInputListWrapper = ({ + items, + width = defaultWidth, + height = defaultHeight, +}) => { + return ( +
+ {}} + /> +
+ ); +}; + +const longIdentifier = + 'lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit-quisque-elementum-nulla'; + +const servers: types.Server[] = [ + { + uri: '/clusters/localhost/servers/foo' as const, + tunnel: false, + name: '2018454d-ef3b-4b15-84f7-61ca213d37e3', + hostname: 'foo', + addr: 'foo.localhost', + labelsList: [ + { name: 'env', value: 'prod' }, + { name: 'kernel', value: '5.15.0-1023-aws' }, + ], + }, + { + uri: '/clusters/localhost/servers/bar' as const, + tunnel: false, + name: '24c7aebe-4741-4464-ab69-f076fe467ebd', + hostname: 'bar', + addr: 'bar.localhost', + labelsList: [ + { name: 'env', value: 'staging' }, + { name: 'kernel', value: '5.14.1-1058-aws' }, + ], + }, + { + uri: '/clusters/localhost/servers/lorem' as const, + tunnel: false, + name: '24c7aebe-4741-4464-ab69-f076fe467ebd', + hostname: longIdentifier, + addr: 'lorem.localhost', + labelsList: [ + { name: 'env', value: 'staging' }, + { name: 'kernel', value: '5.14.1-1058-aws' }, + { name: 'lorem', value: longIdentifier }, + { name: 'kernel2', value: '5.14.1-1058-aws' }, + { name: 'env2', value: 'staging' }, + { name: 'kernel3', value: '5.14.1-1058-aws' }, + ], + }, +]; + +const databases: types.Database[] = [ + { + uri: '/clusters/localhost/dbs/postgres' as const, + name: 'postgres', + desc: 'A PostgreSQL database', + protocol: 'postgres', + type: 'self-hosted', + hostname: 'postgres.localhost', + addr: 'postgres.localhost', + labelsList: [ + { name: 'env', value: 'prod' }, + { name: 'kernel', value: '5.15.0-1023-aws' }, + ], + }, + { + uri: '/clusters/localhost/dbs/mysql' as const, + name: 'mysql', + desc: 'A MySQL database', + protocol: 'mysql', + type: 'self-hosted', + hostname: 'mysql.localhost', + addr: 'mysql.localhost', + labelsList: [ + { name: 'env', value: 'staging' }, + { name: 'kernel', value: '5.14.1-1058-aws' }, + ], + }, + { + uri: '/clusters/localhost/dbs/lorem' as const, + name: longIdentifier, + desc: 'Vestibulum ut blandit est, sed dapibus sem. Pellentesque egestas mi eu scelerisque ultricies.', + protocol: 'mysql', + type: 'self-hosted', + hostname: 'lorem.localhost', + addr: 'lorem.localhost', + labelsList: [ + { name: 'env', value: 'staging' }, + { name: 'kernel', value: '5.14.1-1058-aws' }, + { name: 'lorem', value: longIdentifier }, + { name: 'kernel2', value: '5.14.1-1058-aws' }, + { name: 'env2', value: 'staging' }, + { name: 'kernel3', value: '5.14.1-1058-aws' }, + ], + }, +]; + +const cluster = { + uri: '/clusters/localhost' as const, + name: 'Test', + leaf: false, + connected: true, + proxyHost: 'localhost:3080', + authClusterId: '73c4746b-d956-4f16-9848-4e3469f70762', + loggedInUser: { + activeRequestsList: [], + name: 'admin', + acl: {}, + sshLoginsList: ['root', 'ubuntu', 'ansible', longIdentifier], + rolesList: [], + requestableRolesList: [], + suggestedReviewersList: [], + }, +}; diff --git a/packages/teleterm/src/ui/QuickInput/QuickInputList/QuickInputList.tsx b/packages/teleterm/src/ui/QuickInput/QuickInputList/QuickInputList.tsx index 74b4dcd4d..936488a39 100644 --- a/packages/teleterm/src/ui/QuickInput/QuickInputList/QuickInputList.tsx +++ b/packages/teleterm/src/ui/QuickInput/QuickInputList/QuickInputList.tsx @@ -24,6 +24,10 @@ import { Cli, Server, Person, Database } from 'design/Icon'; import * as types from 'teleterm/ui/services/quickInput/types'; const QuickInputList = React.forwardRef((props, ref) => { + // Ideally, this property would be described by the suggestion object itself rather than depending + // on `kind`. But for now we need it just for a single suggestion kind anyway. + const shouldSuggestionsStayInPlace = + props.items[0]?.kind === 'suggestion.cmd'; const activeItemRef = useRef(); const { items, activeItem } = props; if (items.length === 0) { @@ -60,7 +64,7 @@ const QuickInputList = React.forwardRef((props, ref) => { return ( + - {props.item.data.displayName} + {/* Equivalent of flex-shrink: 0, but styled-system doesn't support flex-shrink. */} + + {props.item.data.displayName} + + {props.item.data.description} ); } function SshLoginItem(props: { item: types.SuggestionSshLogin }) { return ( - + @@ -104,7 +112,7 @@ function ServerItem(props: { item: types.SuggestionServer }) { )); return ( - + @@ -125,7 +133,7 @@ function DatabaseItem(props: { item: types.SuggestionDatabase }) { )); return ( - + diff --git a/packages/teleterm/src/ui/QuickInput/useQuickInput.ts b/packages/teleterm/src/ui/QuickInput/useQuickInput.ts index 2a2f9a0f6..e5ebe7bfb 100644 --- a/packages/teleterm/src/ui/QuickInput/useQuickInput.ts +++ b/packages/teleterm/src/ui/QuickInput/useQuickInput.ts @@ -31,7 +31,7 @@ import { import { routing } from 'teleterm/ui/uri'; import { KeyboardShortcutType } from 'teleterm/services/config'; -import { retryWithRelogin } from '../utils'; +import { assertUnreachable, retryWithRelogin } from '../utils'; export default function useQuickInput() { const appContext = useAppContext(); @@ -122,6 +122,17 @@ export default function useQuickInput() { }); break; } + case 'command.tsh-install': { + commandLauncher.executeCommand('tsh-install', undefined); + break; + } + case 'command.tsh-uninstall': { + commandLauncher.executeCommand('tsh-uninstall', undefined); + break; + } + default: { + assertUnreachable(command); + } } quickInputService.clearInputValueAndHide(); diff --git a/packages/teleterm/src/ui/commandLauncher.test.ts b/packages/teleterm/src/ui/commandLauncher.test.ts new file mode 100644 index 000000000..c39a30dce --- /dev/null +++ b/packages/teleterm/src/ui/commandLauncher.test.ts @@ -0,0 +1,36 @@ +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; + +import { CommandLauncher } from './commandLauncher'; + +it('returns tsh install & uninstall autocomplete command on macOS', () => { + const appContext = new MockAppContext({ platform: 'darwin' }); + const commandLauncher = new CommandLauncher(appContext); + const autocompleteCommandNames = commandLauncher + .getAutocompleteCommands() + .map(c => c.displayName); + + expect(autocompleteCommandNames).toContain('tsh install'); + expect(autocompleteCommandNames).toContain('tsh uninstall'); +}); + +it('does not return tsh install & uninstall autocomplete command on Linux', () => { + const appContext = new MockAppContext({ platform: 'linux' }); + const commandLauncher = new CommandLauncher(appContext); + const autocompleteCommandNames = commandLauncher + .getAutocompleteCommands() + .map(c => c.displayName); + + expect(autocompleteCommandNames).not.toContain('tsh install'); + expect(autocompleteCommandNames).not.toContain('tsh uninstall'); +}); + +it('does not return tsh install & uninstall autocomplete command on Windows', () => { + const appContext = new MockAppContext({ platform: 'win32' }); + const commandLauncher = new CommandLauncher(appContext); + const autocompleteCommandNames = commandLauncher + .getAutocompleteCommands() + .map(c => c.displayName); + + expect(autocompleteCommandNames).not.toContain('tsh install'); + expect(autocompleteCommandNames).not.toContain('tsh uninstall'); +}); diff --git a/packages/teleterm/src/ui/commandLauncher.ts b/packages/teleterm/src/ui/commandLauncher.ts index 3fa4cc700..d9870d8ff 100644 --- a/packages/teleterm/src/ui/commandLauncher.ts +++ b/packages/teleterm/src/ui/commandLauncher.ts @@ -24,6 +24,7 @@ import { } from 'teleterm/ui/uri'; import { tsh } from 'teleterm/ui/services/clusters/types'; import { TrackedKubeConnection } from 'teleterm/ui/services/connectionTracker'; +import { Platform } from 'teleterm/mainProcess/types'; const commands = { // For handling "tsh ssh" executed from the command bar. @@ -92,6 +93,50 @@ const commands = { }, }, + 'tsh-install': { + displayName: '', + description: '', + run(ctx: IAppContext) { + ctx.mainProcessClient.symlinkTshMacOs().then( + isSymlinked => { + if (isSymlinked) { + ctx.notificationsService.notifyInfo( + 'tsh successfully installed in PATH' + ); + } + }, + error => { + ctx.notificationsService.notifyError({ + title: 'Could not install tsh in PATH', + description: `Ran into an error: ${error}`, + }); + } + ); + }, + }, + + 'tsh-uninstall': { + displayName: '', + description: '', + run(ctx: IAppContext) { + ctx.mainProcessClient.removeTshSymlinkMacOs().then( + isRemoved => { + if (isRemoved) { + ctx.notificationsService.notifyInfo( + 'tsh successfully removed from PATH' + ); + } + }, + error => { + ctx.notificationsService.notifyError({ + title: 'Could not remove tsh from PATH', + description: `Ran into an error: ${error}`, + }); + } + ); + }, + }, + 'kube-connect': { displayName: '', description: '', @@ -164,19 +209,32 @@ const commands = { } }, }, +}; - 'autocomplete.tsh-ssh': { +const autocompleteCommands: { + displayName: string; + description: string; + platforms?: Array; +}[] = [ + { displayName: 'tsh ssh', description: 'Run shell or execute a command on a remote SSH node', - run() {}, }, - 'autocomplete.tsh-proxy-db': { + { displayName: 'tsh proxy db', - description: - 'Start local TLS proxy for database connections when using Teleport', - run() {}, + description: 'Start a local proxy for a database connection', }, -}; + { + displayName: 'tsh install', + description: 'Install tsh in PATH', + platforms: ['darwin'], + }, + { + displayName: 'tsh uninstall', + description: 'Uninstall tsh from PATH', + platforms: ['darwin'], + }, +]; export class CommandLauncher { appContext: IAppContext; @@ -190,9 +248,12 @@ export class CommandLauncher { } getAutocompleteCommands() { - return Object.entries(commands) - .filter(([key]) => key.startsWith('autocomplete.')) - .map(([key, value]) => ({ name: key, ...value })); + const { platform } = this.appContext.mainProcessClient.getRuntimeSettings(); + + return autocompleteCommands.filter(command => { + const platforms = command.platforms; + return !command.platforms || platforms.includes(platform); + }); } } diff --git a/packages/teleterm/src/ui/fixtures/mocks.ts b/packages/teleterm/src/ui/fixtures/mocks.ts index 706df9dbc..d81eb0d13 100644 --- a/packages/teleterm/src/ui/fixtures/mocks.ts +++ b/packages/teleterm/src/ui/fixtures/mocks.ts @@ -2,10 +2,11 @@ import { MockMainProcessClient } from 'teleterm/mainProcess/fixtures/mocks'; import { MockTshClient } from 'teleterm/services/tshd/fixtures/mocks'; import { MockPtyServiceClient } from 'teleterm/services/pty/fixtures/mocks'; import AppContext from 'teleterm/ui/appContext'; +import { RuntimeSettings } from 'teleterm/types'; export class MockAppContext extends AppContext { - constructor() { - const mainProcessClient = new MockMainProcessClient(); + constructor(runtimeSettings?: Partial) { + const mainProcessClient = new MockMainProcessClient(runtimeSettings); const tshdClient = new MockTshClient(); const ptyServiceClient = new MockPtyServiceClient(); diff --git a/packages/teleterm/src/ui/services/quickInput/parsers.ts b/packages/teleterm/src/ui/services/quickInput/parsers.ts index bfcfb1b17..f12b0bce0 100644 --- a/packages/teleterm/src/ui/services/quickInput/parsers.ts +++ b/packages/teleterm/src/ui/services/quickInput/parsers.ts @@ -17,6 +17,7 @@ limitations under the License. import { CommandLauncher } from 'teleterm/ui/commandLauncher'; import { + AutocompleteCommand, AutocompleteToken, SuggestionCmd, QuickInputParser, @@ -42,7 +43,6 @@ export class QuickCommandParser implements QuickInputParser { // TODO(ravicious): Handle env vars. parse(rawInput: string): ParseResult { - const autocompleteCommands = this.launcher.getAutocompleteCommands(); // We can safely ignore any whitespace at the start. However, `startIndex` needs to account for // any removed whitespace. const input = rawInput.trimStart(); @@ -53,6 +53,8 @@ export class QuickCommandParser implements QuickInputParser { // Return all commands if there's no input. if (input === '') { + const autocompleteCommands = this.launcher.getAutocompleteCommands(); + return { targetToken, command: { kind: 'command.unknown' }, @@ -134,15 +136,23 @@ export class QuickCommandParser implements QuickInputParser { } private mapAutocompleteCommandsToSuggestions( - commands: { name: string; displayName: string; description: string }[] + commands: { displayName: string; description: string }[] ): SuggestionCmd[] { - return commands.map(cmd => ({ - kind: 'suggestion.cmd' as const, - token: cmd.displayName, + return commands.map(cmd => { + const acceptsNoArguments = + this.parserRegistry.get(cmd.displayName) instanceof + QuickNoArgumentsParser; + // If a command accepts arguments, let's append a space when that suggestion is picked. // This allows us to immediately show autocomplete for the first argument of the command. - appendToToken: ' ', - data: cmd, - })); + const appendToToken = acceptsNoArguments ? '' : ' '; + + return { + kind: 'suggestion.cmd' as const, + token: cmd.displayName, + appendToToken: appendToToken, + data: cmd, + }; + }); } } @@ -307,3 +317,24 @@ export class QuickTshProxyDbParser implements QuickInputParser { }; } } + +// QuickNoArgumentsParser is useful in situations where a command does not accept any arguments. +// If QuickNoArgumentsParser is registered as the parser for that command, selecting that command +// from suggestions will simply close autocomplete. Pressing Enter again will execute the command +// passed to the constructor of QuickNoArgumentsParser. +export class QuickNoArgumentsParser implements QuickInputParser { + constructor(private command: AutocompleteCommand) {} + + parse(rawInput: string, startIndex: number): ParseResult { + const targetToken = { + value: '', + startIndex, + }; + + return { + targetToken, + command: this.command, + getSuggestions: noSuggestions, + }; + } +} diff --git a/packages/teleterm/src/ui/services/quickInput/quickInputService.test.ts b/packages/teleterm/src/ui/services/quickInput/quickInputService.test.ts index dc66d47c7..ae60bfc49 100644 --- a/packages/teleterm/src/ui/services/quickInput/quickInputService.test.ts +++ b/packages/teleterm/src/ui/services/quickInput/quickInputService.test.ts @@ -537,7 +537,6 @@ test('picking a command suggestion in an empty input autocompletes the command', kind: 'suggestion.cmd', token: 'tsh ssh', data: { - name: 'autocomplete.tsh-ssh', displayName: 'tsh ssh', description: '', }, @@ -564,7 +563,6 @@ test('picking a command suggestion in an input with a single space preserves the kind: 'suggestion.cmd', token: 'tsh ssh', data: { - name: 'autocomplete.tsh-ssh', displayName: 'tsh ssh', description: '', }, diff --git a/packages/teleterm/src/ui/services/quickInput/quickInputService.ts b/packages/teleterm/src/ui/services/quickInput/quickInputService.ts index 7e4dca341..1d114c7bc 100644 --- a/packages/teleterm/src/ui/services/quickInput/quickInputService.ts +++ b/packages/teleterm/src/ui/services/quickInput/quickInputService.ts @@ -68,6 +68,14 @@ export class QuickInputService extends Store { 'tsh proxy db', new parsers.QuickTshProxyDbParser(databaseSuggester) ); + this.quickCommandParser.registerParserForCommand( + 'tsh install', + new parsers.QuickNoArgumentsParser({ kind: 'command.tsh-install' }) + ); + this.quickCommandParser.registerParserForCommand( + 'tsh uninstall', + new parsers.QuickNoArgumentsParser({ kind: 'command.tsh-uninstall' }) + ); } state: State = { diff --git a/packages/teleterm/src/ui/services/quickInput/types.ts b/packages/teleterm/src/ui/services/quickInput/types.ts index 77831a7e0..930d54b0c 100644 --- a/packages/teleterm/src/ui/services/quickInput/types.ts +++ b/packages/teleterm/src/ui/services/quickInput/types.ts @@ -9,7 +9,7 @@ type SuggestionBase = { export type SuggestionCmd = SuggestionBase< 'suggestion.cmd', - { name: string; displayName: string; description: string } + { displayName: string; description: string } >; export type SuggestionSshLogin = SuggestionBase< @@ -62,6 +62,12 @@ export type AutocompleteTshSshCommand = CommandBase<'command.tsh-ssh'> & { loginHost: string; }; +export type AutocompleteTshInstallCommand = CommandBase<'command.tsh-install'>; +export type AutocompleteTshUninstallCommand = + CommandBase<'command.tsh-uninstall'>; + export type AutocompleteCommand = | AutocompleteUnknownCommand - | AutocompleteTshSshCommand; + | AutocompleteTshSshCommand + | AutocompleteTshInstallCommand + | AutocompleteTshUninstallCommand;