Skip to content

Commit

Permalink
Refactor CLI version selection (#593)
Browse files Browse the repository at this point in the history
  • Loading branch information
jribbink authored Apr 17, 2024
1 parent 306af1c commit a321cba
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 173 deletions.
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()
}
}
45 changes: 27 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,31 @@ export class CliSelectionProvider {
}
})
} else if (selected instanceof AvailableBinaryItem) {
void this.#cliProvider.setCurrentBinary(selected.path)
void this.#cliProvider.setCurrentBinary(selected.command)
}
}))

this.#disposables.push(versionSelector.onDidHide(() => {
void this.#toggleSelector(false)
}))

// 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 +112,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 +143,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

0 comments on commit a321cba

Please sign in to comment.