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

Refactor CLI version selection #593

Merged
merged 16 commits into from
Apr 17, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Installer, InstallerConstructor, InstallerContext } from '../installer'
import * as semver from 'semver'
import fetch from 'node-fetch'
import { HomebrewInstaller } from './homebrew-installer'
import { KNOWN_FLOW_COMMANDS } from '../../flow-cli/cli-versions-provider'

// Command to check flow-cli
const COMPATIBLE_FLOW_CLI_VERSIONS = '>=1.6.0'
Expand Down Expand Up @@ -97,10 +98,9 @@ export class InstallFlowCLI extends Installer {
}
}

async checkVersion (vsn?: semver.SemVer): Promise<boolean> {
async checkVersion (version: semver.SemVer): Promise<boolean> {
// Get user's version informaton
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, {
Expand Down Expand Up @@ -128,7 +128,11 @@ export class InstallFlowCLI extends Installer {
async verifyInstall (): Promise<boolean> {
// Check if flow version is valid to verify install
this.#context.cliProvider.refresh()
const version = await this.#context.cliProvider.getAvailableBinaries().then(x => x.find(y => y.name === 'flow')?.version)
const installedVersions = await this.#context.cliProvider.getBinaryVersions().catch((e) => {
void window.showErrorMessage(`Failed to check CLI version: ${String(e.message)}`)
return []
})
const version = installedVersions.find(y => y.command === KNOWN_FLOW_COMMANDS.DEFAULT)?.version
if (version == null) return false

// Check flow-cli version number
Expand Down
176 changes: 35 additions & 141 deletions extension/src/flow-cli/cli-provider.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,37 @@
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 --output=json`
const CHECK_FLOW_CLI_CMD_NO_JSON = (flowCommand: string): string => `${flowCommand} version`

const KNOWN_BINS = ['flow', 'flow-c1']

const CADENCE_V1_CLI_REGEX = /-cadence-v1.0.0/g
const LEGACY_VERSION_REGEXP = /Version:\s*(v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(\s|$)/m

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

interface FlowVersionOutput {
version: string
}

interface AvailableBinariesCache {
[key: string]: StateCache<CliBinary | null>
}
import { CliBinary, CliVersionsProvider, KNOWN_FLOW_COMMANDS } from './cli-versions-provider'

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

constructor (settings: Settings) {
const initialBinaryPath = settings.getSettings().flowCommand

this.#settings = settings
this.#cliVersionsProvider = new CliVersionsProvider([initialBinaryPath])
this.#selectedBinaryName = new BehaviorSubject<string>(initialBinaryPath)
this.#currentBinary$ = new StateCache(async () => {
const name: string = this.#selectedBinaryName.getValue()
const versionCache = this.#cliVersionsProvider.get(name)
if (versionCache == null) return null
return await versionCache.getValue()
})

this.#selectedBinaryName = new BehaviorSubject<string>(settings.getSettings().flowCommand)
// Bind the selected binary to the settings
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') {
this.currentBinary$.subscribe((binary) => {
if (binary === null && this.#selectedBinaryName.getValue() !== KNOWN_FLOW_COMMANDS.DEFAULT) {
void vscode.window.showErrorMessage(`The configured Flow CLI binary "${this.#selectedBinaryName.getValue()}" does not exist. Please check your settings.`)
}
})
Expand All @@ -72,119 +42,43 @@ export class CliProvider {
#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()
})
}
// Remove the previous binary from the cache
if (prev != null) this.#cliVersionsProvider.remove(prev)

// Add the current binary to the cache
if (curr != null) this.#cliVersionsProvider.add(curr)

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

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

// Fetches the binary information for the given binary
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
const versionInfo: FlowVersionOutput = JSON.parse(buffer)

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

return { name: bin, version }
} catch {
// Fallback to old method if JSON is not supported/fails
return await this.#fetchBinaryInformationOld(bin)
}
}

// Old version of fetchBinaryInformation (before JSON was supported)
// Used as fallback for old CLI versions
async #fetchBinaryInformationOld (bin: string): Promise<CliBinary | null> {
try {
// Get user's version informaton
const output = (await execDefault(CHECK_FLOW_CLI_CMD_NO_JSON(
bin
)))

let versionStr: string | null = parseFlowCliVersion(output.stdout)
if (versionStr === null) {
// Try to fallback to stderr as patch for bugged version
versionStr = parseFlowCliVersion(output.stderr)
}

versionStr = versionStr != null ? semver.clean(versionStr) : null
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 getCurrentBinary (): Promise<CliBinary | null> {
return await this.#currentBinary$.getValue()
}

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)
}
async setCurrentBinary (name: string): Promise<void> {
if (vscode.workspace.workspaceFolders == null) {
await this.#settings.updateSettings({ flowCommand: name }, vscode.ConfigurationTarget.Global)
} else {
await this.#settings.updateSettings({ flowCommand: name })
}
return bins
}

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

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

async setCurrentBinary (name: string): Promise<void> {
await this.#settings.updateSettings({ flowCommand: name })
get binaryVersions$ (): Observable<CliBinary[]> {
return this.#cliVersionsProvider.versions$.pipe(distinctUntilChanged(isEqual))
}
}

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

export function parseFlowCliVersion (buffer: Buffer | string): string | null {
return buffer.toString().match(LEGACY_VERSION_REGEXP)?.[1] ?? null
// Refresh all cached binary versions
refresh (): void {
this.#cliVersionsProvider.refresh()
}
}
41 changes: 23 additions & 18 deletions extension/src/flow-cli/cli-selection-provider.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as vscode from 'vscode'
import { zip } from 'rxjs'
import { CliBinary, CliProvider } from './cli-provider'
import { CliProvider } from './cli-provider'
import { SemVer } from 'semver'
import * as vscode from 'vscode'
import { CliBinary } from './cli-versions-provider'

const CHANGE_CADENCE_VERSION = 'cadence.changeCadenceVersion'
const CHANGE_CLI_BINARY = 'cadence.changeFlowCliBinary'
const CADENCE_V1_CLI_REGEX = /-cadence-v1.0.0/g
// label with icon
const GET_BINARY_LABEL = (version: SemVer): string => `Flow CLI v${version.format()}`
Expand All @@ -19,13 +20,13 @@ export class CliSelectionProvider {
this.#cliProvider = cliProvider

// Register the command to toggle the version
this.#disposables.push(vscode.commands.registerCommand(CHANGE_CADENCE_VERSION, async () => {
this.#disposables.push(vscode.commands.registerCommand(CHANGE_CLI_BINARY, async () => {
this.#cliProvider.refresh()
await this.#toggleSelector(true)
}))

// Register UI components
zip(this.#cliProvider.currentBinary$, this.#cliProvider.availableBinaries$).subscribe(() => {
zip(this.#cliProvider.currentBinary$, this.#cliProvider.binaryVersions$).subscribe(() => {
void this.#refreshSelector()
})
this.#cliProvider.currentBinary$.subscribe((binary) => {
Expand All @@ -37,7 +38,7 @@ export class CliSelectionProvider {

#createStatusBarItem (version: SemVer | null): vscode.StatusBarItem {
const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 1)
statusBarItem.command = CHANGE_CADENCE_VERSION
statusBarItem.command = CHANGE_CLI_BINARY
statusBarItem.color = new vscode.ThemeColor('statusBar.foreground')
statusBarItem.tooltip = 'Click to change the Flow CLI version'

Expand Down Expand Up @@ -74,23 +75,27 @@ export class CliSelectionProvider {
}
})
} else if (selected instanceof AvailableBinaryItem) {
void this.#cliProvider.setCurrentBinary(selected.path)
void this.#cliProvider.setCurrentBinary(selected.command)
}
}))

// Update available versions
const items: Array<AvailableBinaryItem | CustomBinaryItem> = availableBinaries.map(binary => new AvailableBinaryItem(binary))
items.push(new CustomBinaryItem())
versionSelector.items = items

// Select the current binary
if (currentBinary !== null) {
const currentBinaryItem = versionSelector.items.find(item => item instanceof AvailableBinaryItem && item.path === currentBinary.name)
if (currentBinaryItem != null) {
versionSelector.selectedItems = [currentBinaryItem]
}
// Hoist the current binary to the top of the list
const currentBinaryIndex = items.findIndex(item =>
item instanceof AvailableBinaryItem &&
currentBinary != null &&
item.command === currentBinary.command
)
if (currentBinaryIndex !== -1) {
const currentBinaryItem = items[currentBinaryIndex]
items.splice(currentBinaryIndex, 1)
items.unshift(currentBinaryItem)
}

versionSelector.items = items
return versionSelector
}

Expand All @@ -103,7 +108,7 @@ export class CliSelectionProvider {
if (this.#showSelector) {
this.#versionSelector?.dispose()
const currentBinary = await this.#cliProvider.getCurrentBinary()
const availableBinaries = await this.#cliProvider.getAvailableBinaries()
const availableBinaries = await this.#cliProvider.getBinaryVersions()
this.#versionSelector = this.#createVersionSelector(currentBinary, availableBinaries)
this.#disposables.push(this.#versionSelector)
this.#versionSelector.show()
Expand Down Expand Up @@ -134,11 +139,11 @@ class AvailableBinaryItem implements vscode.QuickPickItem {
}

get description (): string {
return `(${this.#binary.name})`
return `(${this.#binary.command})`
}

get path (): string {
return this.#binary.name
get command (): string {
return this.#binary.command
}
}

Expand Down
Loading