Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CLI version switching #560

Merged
merged 17 commits into from Mar 22, 2024
11 changes: 7 additions & 4 deletions extension/src/dependency-installer/dependency-installer.ts
Expand Up @@ -4,7 +4,7 @@ import { Installer, InstallerConstructor, InstallerContext, InstallError } from
import { promptUserErrorMessage } from '../ui/prompts'
import { StateCache } from '../utils/state-cache'
import { LanguageServerAPI } from '../server/language-server'
import { FlowVersionProvider } from '../flow-cli/flow-version-provider'
import { CliProvider } from '../flow-cli/cli-provider'

const INSTALLERS: InstallerConstructor[] = [
InstallFlowCLI
Expand All @@ -15,12 +15,14 @@ export class DependencyInstaller {
missingDependencies: StateCache<Installer[]>
#installerContext: InstallerContext

constructor (languageServerApi: LanguageServerAPI, flowVersionProvider: FlowVersionProvider) {
constructor (languageServerApi: LanguageServerAPI, cliProvider: CliProvider) {
this.#installerContext = {
refreshDependencies: this.checkDependencies.bind(this),
languageServerApi,
flowVersionProvider
cliProvider
}

// Register installers
this.#registerInstallers()

// Create state cache for missing dependencies
Expand Down Expand Up @@ -54,7 +56,8 @@ export class DependencyInstaller {
async checkDependencies (): Promise<void> {
// Invalidate and wait for state to update
// This will trigger the missingDependencies subscriptions
await this.missingDependencies.getValue(true)
this.missingDependencies.invalidate()
await this.missingDependencies.getValue()
}

async installMissing (): Promise<void> {
Expand Down
4 changes: 2 additions & 2 deletions extension/src/dependency-installer/installer.ts
Expand Up @@ -2,15 +2,15 @@
import { window } from 'vscode'
import { envVars } from '../utils/shell/env-vars'
import { LanguageServerAPI } from '../server/language-server'
import { FlowVersionProvider } from '../flow-cli/flow-version-provider'
import { CliProvider } from '../flow-cli/cli-provider'

// InstallError is thrown if install fails
export class InstallError extends Error {}

export interface InstallerContext {
refreshDependencies: () => Promise<void>
languageServerApi: LanguageServerAPI
flowVersionProvider: FlowVersionProvider
cliProvider: CliProvider
}

export type InstallerConstructor = new (context: InstallerContext) => Installer
Expand Down
Expand Up @@ -99,9 +99,9 @@ export class InstallFlowCLI extends Installer {

async checkVersion (vsn?: semver.SemVer): Promise<boolean> {
// Get user's version informaton
this.#context.flowVersionProvider.refresh()
const version = vsn ?? await this.#context.flowVersionProvider.getVersion()
if (version === null) return false
this.#context.cliProvider.refresh()
const version = vsn ?? await this.#context.cliProvider.getAvailableBinaries().then(x => x.find(y => y.name === 'flow')?.version)
if (version == null) return false

if (!semver.satisfies(version, COMPATIBLE_FLOW_CLI_VERSIONS, {
includePrerelease: true
Expand All @@ -127,8 +127,8 @@ export class InstallFlowCLI extends Installer {

async verifyInstall (): Promise<boolean> {
// Check if flow version is valid to verify install
this.#context.flowVersionProvider.refresh()
const version = await this.#context.flowVersionProvider.getVersion()
this.#context.cliProvider.refresh()
const version = await this.#context.cliProvider.getAvailableBinaries().then(x => x.find(y => y.name === 'flow')?.version)
if (version == null) return false

// Check flow-cli version number
Expand Down
22 changes: 15 additions & 7 deletions extension/src/extension.ts
Expand Up @@ -4,14 +4,15 @@ import { CommandController } from './commands/command-controller'
import { ExtensionContext } from 'vscode'
import { DependencyInstaller } from './dependency-installer/dependency-installer'
import { Settings } from './settings/settings'
import { FlowVersionProvider } from './flow-cli/flow-version-provider'
import { CliProvider } from './flow-cli/cli-provider'
import { JSONSchemaProvider } from './json-schema-provider'
import { LanguageServerAPI } from './server/language-server'
import { FlowConfig } from './server/flow-config'
import { TestProvider } from './test-provider/test-provider'
import { StorageProvider } from './storage/storage-provider'
import * as path from 'path'
import { NotificationProvider } from './ui/notification-provider'
import { CliSelectionProvider } from './flow-cli/cli-selection-provider'

// The container for all data relevant to the extension.
export class Extension {
Expand All @@ -30,6 +31,8 @@ export class Extension {
#dependencyInstaller: DependencyInstaller
#commands: CommandController
#testProvider: TestProvider
#schemaProvider: JSONSchemaProvider
#cliSelectionProvider: CliSelectionProvider

private constructor (settings: Settings, ctx: ExtensionContext) {
this.ctx = ctx
Expand All @@ -41,24 +44,27 @@ export class Extension {
const notificationProvider = new NotificationProvider(storageProvider)
notificationProvider.activate()

// Register Flow version provider
const flowVersionProvider = new FlowVersionProvider(settings)
// Register CliProvider
const cliProvider = new CliProvider(settings)

// Register CliSelectionProvider
this.#cliSelectionProvider = new CliSelectionProvider(cliProvider)

// Register JSON schema provider
if (ctx != null) JSONSchemaProvider.register(ctx, flowVersionProvider.state$)
this.#schemaProvider = new JSONSchemaProvider(ctx.extensionPath, cliProvider)

// Initialize Flow Config
const flowConfig = new FlowConfig(settings)
void flowConfig.activate()

// Initialize Language Server
this.languageServer = new LanguageServerAPI(settings, flowConfig)
this.languageServer = new LanguageServerAPI(settings, cliProvider, flowConfig)

// Check for any missing dependencies
// The language server will start if all dependencies are installed
// Otherwise, the language server will not start and will start after
// the user installs the missing dependencies
this.#dependencyInstaller = new DependencyInstaller(this.languageServer, flowVersionProvider)
this.#dependencyInstaller = new DependencyInstaller(this.languageServer, cliProvider)
this.#dependencyInstaller.missingDependencies.subscribe((missing) => {
if (missing.length === 0) {
void this.languageServer.activate()
Expand All @@ -79,6 +85,8 @@ export class Extension {
// Called on exit
async deactivate (): Promise<void> {
await this.languageServer.deactivate()
this.#testProvider?.dispose()
this.#testProvider.dispose()
this.#schemaProvider.dispose()
this.#cliSelectionProvider.dispose()
}
}
156 changes: 156 additions & 0 deletions extension/src/flow-cli/cli-provider.ts
@@ -0,0 +1,156 @@
import { BehaviorSubject, Observable, distinctUntilChanged, pairwise, startWith } from 'rxjs'
import { execDefault } from '../utils/shell/exec'
import { StateCache } from '../utils/state-cache'
import * as semver from 'semver'
import * as vscode from 'vscode'
import { Settings } from '../settings/settings'
import { isEqual } from 'lodash'

const CHECK_FLOW_CLI_CMD = (flowCommand: string): string => `${flowCommand} version`
const KNOWN_BINS = ['flow', 'flow-c1']

const CADENCE_V1_CLI_REGEX = /-cadence-v1.0.0/g

export interface CliBinary {
name: string
version: semver.SemVer
}

interface AvailableBinariesCache {
[key: string]: StateCache<CliBinary | null>
}

export class CliProvider {
#selectedBinaryName: BehaviorSubject<string>
#currentBinary$: StateCache<CliBinary | null>
#availableBinaries: AvailableBinariesCache = {}
#availableBinaries$: StateCache<CliBinary[]>
#settings: Settings

constructor (settings: Settings) {
this.#settings = settings

this.#selectedBinaryName = new BehaviorSubject<string>(settings.getSettings().flowCommand)
this.#settings.watch$(config => config.flowCommand).subscribe((flowCommand) => {
this.#selectedBinaryName.next(flowCommand)
})

this.#availableBinaries = KNOWN_BINS.reduce<AvailableBinariesCache>((acc, bin) => {
acc[bin] = new StateCache(async () => await this.#fetchBinaryInformation(bin))
acc[bin].subscribe(() => {
this.#availableBinaries$.invalidate()
})
return acc
}, {})

this.#availableBinaries$ = new StateCache(async () => {
return await this.getAvailableBinaries()
})

this.#currentBinary$ = new StateCache(async () => {
const name: string = this.#selectedBinaryName.getValue()
return await this.#availableBinaries[name].getValue()
})

// Display warning to user if binary doesn't exist (only if not using the default binary)
this.#currentBinary$.subscribe((binary) => {
if (binary === null && this.#selectedBinaryName.getValue() !== 'flow') {
void vscode.window.showErrorMessage(`The configured Flow CLI binary "${this.#selectedBinaryName.getValue()}" does not exist. Please check your settings.`)
}
})

this.#watchForBinaryChanges()
}

#watchForBinaryChanges (): void {
// Subscribe to changes in the selected binary to update the caches
this.#selectedBinaryName.pipe(distinctUntilChanged(), startWith(null), pairwise()).subscribe(([prev, curr]) => {
// Swap out the cache for the selected binary
if (prev != null && !KNOWN_BINS.includes(prev)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.#availableBinaries[prev]
}
if (curr != null && !KNOWN_BINS.includes(curr)) {
this.#availableBinaries[curr] = new StateCache(async () => await this.#fetchBinaryInformation(curr))
this.#availableBinaries[curr].subscribe(() => {
this.#availableBinaries$.invalidate()
})
}

// Invalidate the current binary cache
this.#currentBinary$.invalidate()

// Invalidate the available binaries cache
this.#availableBinaries$.invalidate()
})
}

async #fetchBinaryInformation (bin: string): Promise<CliBinary | null> {
try {
// Get user's version informaton
const buffer: string = (await execDefault(CHECK_FLOW_CLI_CMD(
bin
))).stdout

// Format version string from output
let versionStr: string | null = parseFlowCliVersion(buffer)

versionStr = semver.clean(versionStr)
if (versionStr === null) return null

// Ensure user has a compatible version number installed
const version: semver.SemVer | null = semver.parse(versionStr)
if (version === null) return null

return { name: bin, version }
} catch {
return null
}
}

refresh (): void {
for (const bin in this.#availableBinaries) {
this.#availableBinaries[bin].invalidate()
}
this.#currentBinary$.invalidate()
}

get availableBinaries$ (): Observable<CliBinary[]> {
return new Observable((subscriber) => {
this.#availableBinaries$.subscribe((binaries) => {
subscriber.next(binaries)
})
}).pipe(distinctUntilChanged(isEqual))
}

async getAvailableBinaries (): Promise<CliBinary[]> {
const bins: CliBinary[] = []
for (const name in this.#availableBinaries) {
const binary = await this.#availableBinaries[name].getValue().catch(() => null)
if (binary !== null) {
bins.push(binary)
}
}
return bins
}

get currentBinary$ (): Observable<CliBinary | null> {
return this.#currentBinary$.pipe(distinctUntilChanged(isEqual))
}

async getCurrentBinary (): Promise<CliBinary | null> {
return await this.#currentBinary$.getValue()
}

async setCurrentBinary (name: string): Promise<void> {
await this.#settings.updateSettings({ flowCommand: name })
}
}

export function isCadenceV1Cli (version: semver.SemVer): boolean {
return CADENCE_V1_CLI_REGEX.test(version.raw)
}

export function parseFlowCliVersion (buffer: Buffer | string): string {
return (buffer.toString().split('\n')[0]).split(' ')[1]
}