Skip to content

Commit

Permalink
Add CLI version switching (#560)
Browse files Browse the repository at this point in the history
  • Loading branch information
jribbink authored Mar 22, 2024
1 parent 0a55ede commit 1ac5a1f
Show file tree
Hide file tree
Showing 18 changed files with 539 additions and 578 deletions.
11 changes: 7 additions & 4 deletions extension/src/dependency-installer/dependency-installer.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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]
}
Loading

0 comments on commit 1ac5a1f

Please sign in to comment.