diff --git a/node-explorer-TODO.txt b/node-explorer-TODO.txt new file mode 100644 index 0000000..534692c --- /dev/null +++ b/node-explorer-TODO.txt @@ -0,0 +1,8 @@ +* Handle offline, or unavailable - show error +* Add refresh action +* change user (node context) +* change root directory (node and FileExplorer context) +* figure out why the progress bar doesn't show up +* add file explorer context (get path, delete, rename) +* download button +* \ No newline at end of file diff --git a/package.json b/package.json index 17e2017..8229618 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,29 @@ { "command": "tailscale.openAdminConsole", "group": "overflow", - "when": "view == tailscale-serve-view" + "when": "view == tailscale-serve-view || view == tailscale-node-explorer-view" + } + ], + "view/item/context": [ + { + "command": "tailscale.copyIPv4", + "when": "view == tailscale-node-explorer-view && viewItem == tailscale-peer-item", + "group": "1_peer@1" + }, + { + "command": "tailscale.copyIPv6", + "when": "view == tailscale-node-explorer-view && viewItem == tailscale-peer-item", + "group": "1_peer@1" + }, + { + "command": "tailscale.copyHostname", + "when": "view == tailscale-node-explorer-view && viewItem == tailscale-peer-item", + "group": "1_peer@1" + }, + { + "command": "tailscale.ssh.delete", + "group": "fileActions@2", + "when": "view == tailscale-node-explorer-view && viewItem == file-explorer-item" } ] }, @@ -164,6 +186,22 @@ "command": "tailscale.simpleServeView", "title": "Simple View", "category": "tsdev" + }, + { + "command": "tailscale.copyIPv4", + "title": "Copy IPv4" + }, + { + "command": "tailscale.copyIPv6", + "title": "Copy IPv6" + }, + { + "command": "tailscale.copyHostname", + "title": "Copy Hostname" + }, + { + "command": "tailscale.ssh.delete", + "title": "Delete" } ], "viewsContainers": { @@ -173,6 +211,13 @@ "title": "Tailscale", "icon": "images/tailscale.svg" } + ], + "activitybar": [ + { + "icon": "resources/images/mark.svg", + "id": "tailscale-nodes-explorer", + "title": "Tailscale" + } ] }, "views": { @@ -182,6 +227,13 @@ "name": "Funnel", "type": "webview" } + ], + "tailscale-nodes-explorer": [ + { + "id": "tailscale-node-explorer-view", + "name": "Nodes", + "when": "config.tailscale.nodeExplorer.enabled" + } ] }, "configuration": [ @@ -207,6 +259,11 @@ "examples": [ false ] + }, + "tailscale.nodeExplorer.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "(IN DEVELOPMENT) Enable the Tailscale Node Explorer view." } } } diff --git a/resources/dark/offline.svg b/resources/dark/offline.svg new file mode 100644 index 0000000..270be8d --- /dev/null +++ b/resources/dark/offline.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/dark/online.svg b/resources/dark/online.svg new file mode 100644 index 0000000..a36bcea --- /dev/null +++ b/resources/dark/online.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/dark/terminal.svg b/resources/dark/terminal.svg new file mode 100644 index 0000000..af459c0 --- /dev/null +++ b/resources/dark/terminal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/images/mark.svg b/resources/images/mark.svg new file mode 100644 index 0000000..e71aa90 --- /dev/null +++ b/resources/images/mark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/resources/light/offline.svg b/resources/light/offline.svg new file mode 100644 index 0000000..270be8d --- /dev/null +++ b/resources/light/offline.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/light/online.svg b/resources/light/online.svg new file mode 100644 index 0000000..a36bcea --- /dev/null +++ b/resources/light/online.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/light/terminal.svg b/resources/light/terminal.svg new file mode 100644 index 0000000..af459c0 --- /dev/null +++ b/resources/light/terminal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index f51cc3d..53665b9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,14 @@ import { ADMIN_CONSOLE } from './utils/url'; import { Tailscale } from './tailscale'; import { Logger } from './logger'; import { errorForType } from './tailscale/error'; +import { + FileExplorer, + NodeExplorerProvider, + PeerDetailTreeItem, + PeerTree, +} from './node-explorer-provider'; + +import { TSFileSystemProvider } from './ts-file-system-provider'; let tailscaleInstance: Tailscale; @@ -45,6 +53,22 @@ export async function activate(context: vscode.ExtensionContext) { tailscaleInstance ); + const tsObjFileSystemProvider = new TSFileSystemProvider(); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider('ts', tsObjFileSystemProvider, { + isCaseSensitive: true, + }) + ); + + const nodeExplorerProvider = new NodeExplorerProvider(tailscaleInstance); + vscode.window.registerTreeDataProvider('tailscale-node-explorer-view', nodeExplorerProvider); + const view = vscode.window.createTreeView('tailscale-node-explorer-view', { + treeDataProvider: nodeExplorerProvider, + showCollapseAll: true, + dragAndDropController: nodeExplorerProvider, + }); + context.subscriptions.push(view); + context.subscriptions.push( vscode.commands.registerCommand('tailscale.refreshServe', () => { Logger.info('called tailscale.refreshServe', 'command'); @@ -74,6 +98,36 @@ export async function activate(context: vscode.ExtensionContext) { }) ); + context.subscriptions.push( + vscode.commands.registerCommand('tailscale.copyIPv4', async (node: PeerTree) => { + const ip = node.TailscaleIPs[0]; + + if (!ip) { + vscode.window.showErrorMessage(`No IPv4 address found for ${node.HostName}.`); + return; + } + + await vscode.env.clipboard.writeText(ip); + vscode.window.showInformationMessage(`Copied ${ip} to clipboard.`); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('tailscale.copyIPv6', async (node: PeerTree) => { + const ip = node.TailscaleIPs[1]; + await vscode.env.clipboard.writeText(ip); + vscode.window.showInformationMessage(`Copied ${ip} to clipboard.`); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('tailscale.copyHostname', async (node: PeerTree) => { + const name = node.HostName; + await vscode.env.clipboard.writeText(name); + vscode.window.showInformationMessage(`Copied ${name} to clipboard.`); + }) + ); + vscode.window.registerWebviewViewProvider('tailscale-serve-view', servePanelProvider); context.subscriptions.push( diff --git a/src/node-explorer-provider.ts b/src/node-explorer-provider.ts new file mode 100644 index 0000000..e72892c --- /dev/null +++ b/src/node-explorer-provider.ts @@ -0,0 +1,257 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { FileInfo, Peer, Status } from './types'; +import { Tailscale } from './tailscale/cli'; +import { TSFileSystemProvider } from './ts-file-system-provider'; + +export class NodeExplorerProvider + implements + vscode.TreeDataProvider, + vscode.TreeDragAndDropController, + vscode.FileDecorationProvider +{ + dropMimeTypes = ['text/uri-list']; // add 'application/vnd.code.tree.testViewDragAndDrop' when we have file explorer + dragMimeTypes = []; + + private _onDidChangeTreeData: vscode.EventEmitter<(PeerBaseTreeItem | undefined)[] | undefined> = + new vscode.EventEmitter(); + + // We want to use an array as the event type, but the API for this is currently being finalized. Until it's finalized, use any. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + disposable: vscode.Disposable; + + private peers: { [hostName: string]: Peer } = {}; + private fsProvider: TSFileSystemProvider; + + constructor(private readonly ts: Tailscale) { + this.disposable = vscode.window.registerFileDecorationProvider(this); + this.fsProvider = new TSFileSystemProvider(); + + this.registerDeleteCommand(); + } + + dispose() { + this.disposable.dispose(); + } + + onDidChangeFileDecorations?: vscode.Event | undefined; + + provideFileDecoration( + uri: vscode.Uri, + _: vscode.CancellationToken + ): vscode.ProviderResult { + if (uri.scheme === 'tsobj') { + const p = this.peers[uri.authority]; + if (p?.sshHostKeys?.length) { + return { + badge: '>_', + tooltip: 'You can drag and drop files to this node', + }; + } + } + return {}; + } + + getTreeItem(element: PeerBaseTreeItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: PeerBaseTreeItem): Promise { + // File Explorer + if (element instanceof FileExplorer) { + const dirents = await vscode.workspace.fs.readDirectory(element.uri); + return dirents.map(([name, type]) => { + const childUri = element.uri.with({ path: `${element.uri.path}/${name}` }); + return new FileExplorer(name, childUri, type); + }); + } + + // Node root + if (element instanceof PeerTree) { + return [ + new FileExplorer( + 'File Explorer', + // TODO: allow the directory to be configurable + vscode.Uri.parse(`ts://nodes/${element.HostName}/~`), + vscode.FileType.Directory + ), + ]; + } else { + // Peer List + + const peers: PeerTree[] = []; + + try { + const status = await this.ts.status(); + if (status.Errors && status.Errors.length) { + // TODO: return a proper error + return []; + } + for (const key in status.Peer) { + const p = status.Peer[key]; + this.peers[p.HostName] = p; + + peers.push(new PeerTree({ ...p })); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + vscode.window.showErrorMessage(`unable to fetch status ${e.message}`); + console.error(`Error fetching status: ${e}`); + } + + return peers; + } + } + + public async handleDrop(target: FileExplorer, dataTransfer: vscode.DataTransfer): Promise { + console.log('handleDrop', target, dataTransfer); + // TODO: figure out why the progress bar doesn't show up + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + cancellable: false, + title: 'Tailscale', + }, + async (progress) => { + dataTransfer.forEach(async ({ value }) => { + const uri = vscode.Uri.parse(value); + console.log('uri', uri); + + try { + await this.fsProvider.scp(uri, target?.uri); + console.log('scp done'); + } catch (e) { + vscode.window.showErrorMessage(`unable to copy ${uri} to ${target?.uri}`); + console.error(`Error copying ${uri} to ${target?.uri}: ${e}`); + } + + progress.report({ increment: 100 }); + this._onDidChangeTreeData.fire([target]); + }); + } + ); + + if (!target) { + return; + } + } + + public async handleDrag( + source: PeerTree[], + treeDataTransfer: vscode.DataTransfer, + _: vscode.CancellationToken + ): Promise { + treeDataTransfer.set( + 'application/vnd.code.tree.testViewDragAndDrop', + new vscode.DataTransferItem(source) + ); + } + + registerDeleteCommand() { + vscode.commands.registerCommand('tailscale.ssh.delete', this.delete.bind(this)); + } + + async delete(file: FileExplorer) { + try { + await vscode.workspace.fs.delete(file.uri); + vscode.window.showInformationMessage(`${file.label} deleted successfully.`); + + const normalizedPath = path.normalize(file.uri.toString()); + const parentDir = path.dirname(normalizedPath); + const dirName = path.basename(parentDir); + + const parentFileExplorerItem = new FileExplorer( + dirName, + vscode.Uri.parse(parentDir), + vscode.FileType.Directory + ); + + this._onDidChangeTreeData.fire([parentFileExplorerItem]); + console.log('parentFileExplorerItem', parentFileExplorerItem); + } catch (e) { + vscode.window.showErrorMessage(`Could not delete ${file.label}: ${e}`); + } + } +} + +export class PeerBaseTreeItem extends vscode.TreeItem { + constructor(label: string) { + super(vscode.Uri.parse(`tsobj://${label}`)); + this.label = label; + } +} + +export class FileExplorer extends vscode.TreeItem { + constructor( + public readonly label: string, + public readonly uri: vscode.Uri, + public readonly type: vscode.FileType, + public readonly collapsibleState: vscode.TreeItemCollapsibleState = type === + vscode.FileType.Directory + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None + ) { + super(label, collapsibleState); + + if (type === vscode.FileType.File) { + this.command = { + command: 'vscode.open', + title: 'Open File', + arguments: [this.uri], + }; + } + } + + contextValue = 'file-explorer-item'; +} + +export class PeerTree extends PeerBaseTreeItem { + public ID: string; + public HostName: string; + public TailscaleIPs: string[]; + + public constructor(obj: Peer) { + super(obj.HostName); + + this.ID = obj.ID; + this.HostName = obj.HostName; + this.TailscaleIPs = obj.TailscaleIPs; + + this.iconPath = { + light: path.join( + __filename, + '..', + '..', + 'resources', + 'light', + obj.Online === true ? 'online.svg' : 'offline.svg' + ), + dark: path.join( + __filename, + '..', + '..', + 'resources', + 'dark', + obj.Online === true ? 'online.svg' : 'offline.svg' + ), + }; + + this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; + } + + contextValue = 'tailscale-peer-item'; +} + +export class PeerDetailTreeItem extends PeerBaseTreeItem { + constructor(label: string, description: string, contextValue?: string) { + super(label); + this.description = description; + this.label = label; + if (contextValue) { + this.contextValue = contextValue; + } + } +} diff --git a/src/tailscale/cli.ts b/src/tailscale/cli.ts index a4b5cb1..470b252 100644 --- a/src/tailscale/cli.ts +++ b/src/tailscale/cli.ts @@ -2,7 +2,7 @@ import * as cp from 'child_process'; import * as vscode from 'vscode'; import fetch from 'node-fetch'; import * as WebSocket from 'ws'; -import type { ServeParams, ServeStatus, TSRelayDetails } from '../types'; +import type { ServeParams, ServeStatus, TSRelayDetails, Status, FileInfo } from '../types'; import { Logger } from '../logger'; import * as path from 'node:path'; import { LogLevel } from 'vscode'; @@ -113,7 +113,7 @@ export class Tailscale { if (process.env.NODE_ENV === 'development') { Logger.info( - `curl "${this.url}/serve" -H "Authorization: Basic ${this.authkey}"`, + `curl -H "Authorization: Basic ${this.authkey}" "${this.url}/serve"`, LOG_COMPONENT ); } @@ -238,6 +238,25 @@ export class Tailscale { } } + async status() { + if (!this.url) { + throw new Error('uninitialized client'); + } + try { + const resp = await fetch(`${this.url}/localapi/v0/status`, { + headers: { + Authorization: 'Basic ' + this.authkey, + }, + }); + + const status = (await resp.json()) as Status; + return status; + } catch (e) { + Logger.error(`error calling status: ${e}`); + throw e; + } + } + async serveStatus(): Promise { if (!this.url) { throw new Error('uninitialized client'); @@ -252,7 +271,7 @@ export class Tailscale { const status = (await resp.json()) as ServeStatus; return status; } catch (e) { - Logger.error(`error calling status: ${JSON.stringify(e, null, 2)}`); + Logger.error(`error calling serve: ${JSON.stringify(e, null, 2)}`); throw e; } } diff --git a/src/ts-file-system-provider.ts b/src/ts-file-system-provider.ts new file mode 100644 index 0000000..7db7f34 --- /dev/null +++ b/src/ts-file-system-provider.ts @@ -0,0 +1,272 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { Tailscale } from './tailscale/cli'; +import { Logger } from './logger'; + +export class File implements vscode.FileStat { + type: vscode.FileType; + ctime: number; + mtime: number; + size: number; + + name: string; + data?: Uint8Array; + + constructor(name: string) { + this.type = vscode.FileType.File; + this.ctime = Date.now(); + this.mtime = Date.now(); + this.size = 0; + this.name = name; + } +} + +export class Directory implements vscode.FileStat { + type: vscode.FileType; + ctime: number; + mtime: number; + size: number; + + name: string; + entries: Map; + + constructor(name: string) { + this.type = vscode.FileType.Directory; + this.ctime = Date.now(); + this.mtime = Date.now(); + this.size = 0; + this.name = name; + this.entries = new Map(); + } +} + +export type Entry = File | Directory; + +export class TSFileSystemProvider implements vscode.FileSystemProvider { + // Implementation of the `onDidChangeFile` event + onDidChangeFile: vscode.Event = new vscode.EventEmitter< + vscode.FileChangeEvent[] + >().event; + + watch(): vscode.Disposable { + throw new Error('Watch not supported'); + } + + stat(uri: vscode.Uri): vscode.FileStat | Thenable { + Logger.info(`stat: ${uri.toString()}`, 'tsobj-fsp'); + const { hostname, resourcePath } = this.extractHostAndPath(uri); + + const command = `ssh ${hostname} "stat -L -c '{\\"type\\": \\"%F\\", \\"size\\": %s, \\"ctime\\": %Z, \\"mtime\\": %Y}' ${resourcePath}"`; + + return new Promise((resolve, reject) => { + exec(command, (error, stdout) => { + if (error) { + reject(error); + } else { + const result = JSON.parse(stdout.trim()); + const type = + result.type === 'directory' ? vscode.FileType.Directory : vscode.FileType.File; + const size = result.size || 0; + const ctime = result.ctime * 1000; + const mtime = result.mtime * 1000; + resolve({ type, size, ctime, mtime }); + } + }); + }); + } + + readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { + Logger.info(`readDirectory: ${uri.toString()}`, 'tsobj-fsp'); + + const { hostname, resourcePath } = this.extractHostAndPath(uri); + Logger.info(`hostname: ${hostname}`, 'tsobj-fsp'); + Logger.info(`remotePath: ${resourcePath}`, 'tsobj-fsp'); + + const command = `ssh ${hostname} ls -Ap "${resourcePath}"`; + return new Promise((resolve, reject) => { + exec(command, (error, stdout) => { + if (error) { + reject(error); + } else { + const lines = stdout.trim().split('\n'); + const files: [string, vscode.FileType][] = []; + for (const line of lines) { + const isDirectory = line.endsWith('/'); + const type = isDirectory ? vscode.FileType.Directory : vscode.FileType.File; + const name = isDirectory ? line.slice(0, -1) : line; // Remove trailing slash if it's a directory + files.push([name, type]); + } + + files.sort((a, b) => { + if (a[1] === vscode.FileType.Directory && b[1] !== vscode.FileType.Directory) { + return -1; + } + if (a[1] !== vscode.FileType.Directory && b[1] === vscode.FileType.Directory) { + return 1; + } + + // If same type, sort by name + return a[0].localeCompare(b[0]); + }); + + resolve(files); + } + }); + }); + } + + readFile(uri: vscode.Uri): Promise { + Logger.info(`readFile: ${uri.toString()}`, 'tsobj-readFile'); + const { hostname, resourcePath } = this.extractHostAndPath(uri); + const command = `ssh ${hostname} "cat ${resourcePath}"`; + return new Promise((resolve, reject) => { + exec(command, (error, stdout) => { + if (error) { + reject(error); + } else { + const buffer = Buffer.from(stdout, 'binary'); + resolve(new Uint8Array(buffer)); + } + }); + }); + } + + writeFile( + uri: vscode.Uri, + content: Uint8Array, + options: { create: boolean; overwrite: boolean } + ): Promise { + Logger.info(`writeFile: ${uri.toString()}`, 'tsobj-fsp'); + + const { hostname, resourcePath } = this.extractHostAndPath(uri); + + if (!options.create && !options.overwrite) { + throw vscode.FileSystemError.FileExists(uri); + } + + const command = `ssh ${hostname} "cat > ${resourcePath}"`; + return new Promise((resolve, reject) => { + const process = exec(command, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + + process.stdin?.end(content); + }); + } + + delete(uri: vscode.Uri, options: { recursive: boolean }): Promise { + Logger.info(`delete: ${uri.toString()}`, 'tsobj-fsp'); + + const { hostname, resourcePath } = this.extractHostAndPath(uri); + + const command = `ssh ${hostname} "rm ${options.recursive ? '-r' : ''} ${resourcePath}"`; + return new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + createDirectory(uri: vscode.Uri): Promise { + Logger.info(`createDirectory: ${uri.toString()}`, 'tsobj-fsp'); + + const { hostname, resourcePath } = this.extractHostAndPath(uri); + + const command = `ssh ${hostname} mkdir -p ${resourcePath}`; + return new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean }): Promise { + Logger.info('rename', 'tsobj-fsp'); + + const { hostname: oldHost, resourcePath: oldPath } = this.extractHostAndPath(oldUri); + const { hostname: newHost, resourcePath: newPath } = this.extractHostAndPath(newUri); + + if (oldHost !== newHost) { + throw new Error('Cannot rename files across different hosts.'); + } + + const command = `ssh ${oldHost} mv ${options.overwrite ? '-f' : ''} ${oldPath} ${newPath}`; + return new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + // scp pi@haas:/home/pi/foo.txt ubuntu@backup:/home/ubuntu/ + // scp /Users/Tyler/foo.txt ubuntu@backup:/home/ubuntu/ + // scp ubuntu@backup:/home/ubuntu/ /Users/Tyler/foo.txt + + scp(src: vscode.Uri, dest: vscode.Uri): Promise { + Logger.info('scp', 'tsobj-fsp'); + + const { hostname: srcHostName, resourcePath: srcPath } = this.extractHostAndPath(src); + const { hostname: destHostName, resourcePath: destPath } = this.extractHostAndPath(dest); + + const command = `scp ${srcPath} ${destHostName}:${destPath}`; + console.log('command', command); + + return new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + private executeShellCommand(command: string): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) { + reject(error); + } else { + resolve({ stdout: stdout.trim(), stderr: stderr.trim() }); + } + }); + }); + } + + private extractHostAndPath(uri: vscode.Uri): { hostname: string | null; resourcePath: string } { + switch (uri.scheme) { + case 'ts': { + // removes leading slash + const hostPath = uri.path.slice(1); + + const segments = path.normalize(hostPath).split('/'); + const [hostname, ...pathSegments] = segments; + const resourcePath = decodeURIComponent(pathSegments.join(path.sep)); + + return { hostname, resourcePath }; + } + case 'file': + return { hostname: null, resourcePath: uri.path }; + default: + throw new Error(`Unsupported scheme: ${uri.scheme}`); + } + } +} diff --git a/src/types.ts b/src/types.ts index 419e99b..7a4a7c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,21 @@ export interface Handlers { Proxy: string; } +export interface Peer { + ID: string; + HostName: string; + Active?: boolean; + Online?: boolean; + TailscaleIPs: string[]; + sshHostKeys?: string[]; +} + +export interface Status extends WithErrors { + Peer: { + [key: string]: Peer; + }; +} + export interface ServeStatus extends WithErrors { ServeConfig?: ServeConfig; FunnelPorts?: number[]; @@ -174,3 +189,9 @@ export interface TSRelayDetails { nonce: string; port: string; } + +export interface FileInfo { + name: string; + isDir: boolean; + path: string; +}