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/binary-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
146 changes: 146 additions & 0 deletions extension/src/flow-cli/binary-versions-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as semver from 'semver'
import { StateCache } from '../utils/state-cache'
import { execDefault } from '../utils/shell/exec'
import { Observable, distinctUntilChanged } from 'rxjs'
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`

export enum KNOWN_FLOW_COMMANDS {
DEFAULT = 'flow',
CADENCE_V1 = 'flow-c1',
}

// This regex matches a string like "Version: v{SEMVER}" and extracts the version number
// It uses the official semver regex from https://semver.org/
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
jribbink marked this conversation as resolved.
Show resolved Hide resolved

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

interface FlowVersionOutput {
version: string
}

export class BinaryVersionsProvider {
#rootCache: StateCache<CliBinary[]>
#caches: { [key: string]: StateCache<CliBinary | null> } = {}

constructor (seedBinaries: string[] = []) {
// Seed the caches with the known binaries
Object.values(KNOWN_FLOW_COMMANDS).forEach((bin) => {
this.add(bin)
})

// Seed the caches with any additional binaries
seedBinaries.forEach((bin) => {
this.add(bin)
})

// Create the root cache. This cache will hold all the binary information
// and is a combination of all the individual caches for each binary
this.#rootCache = new StateCache(async () => {
const binaries = await Promise.all(
Object.keys(this.#caches).map(async (bin) => {
return await this.#caches[bin].getValue().catch(() => null)
})
)

// Filter out missing binaries
return binaries.filter((bin) => bin != null) as CliBinary[]
})
}

add (command: string): void {
if (this.#caches[command] != null) return
this.#caches[command] = new StateCache(async () => await this.#fetchBinaryInformation(command))
this.#caches[command].subscribe(() => {
this.#rootCache?.invalidate()
})
this.#rootCache?.invalidate()
}

remove (command: string): void {
// Known binaries cannot be removed
if (this.#caches[command] == null || (Object.values(KNOWN_FLOW_COMMANDS) as string[]).includes(command)) return
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.#caches[command]
this.#rootCache?.invalidate()
}

get (name: string): StateCache<CliBinary | null> | null {
return this.#caches[name] ?? null
}

// 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 { command: 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> {
jribbink marked this conversation as resolved.
Show resolved Hide resolved
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 { command: bin, version }
} catch {
return null
}
}

refresh (): void {
Object.keys(this.#caches).forEach((bin) => {
this.#caches[bin].invalidate()
})
this.#rootCache.invalidate()
}

async getVersions (): Promise<CliBinary[]> {
return await this.#rootCache.getValue()
}

get versions$ (): Observable<CliBinary[]> {
return this.#rootCache.pipe(distinctUntilChanged(isEqual))
}
}

export function parseFlowCliVersion (buffer: Buffer | string): string | null {
return buffer.toString().match(LEGACY_VERSION_REGEXP)?.[1] ?? null
}
174 changes: 32 additions & 142 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, BinaryVersionsProvider, KNOWN_FLOW_COMMANDS } from './binary-versions-provider'

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

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

this.#settings = settings
this.#binaryVersionsProvider = new BinaryVersionsProvider([initialBinaryPath])
this.#selectedBinaryName = new BehaviorSubject<string>(initialBinaryPath)
this.#currentBinary$ = new StateCache(async () => {
const name: string = this.#selectedBinaryName.getValue()
const versionCache = this.#binaryVersionsProvider.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,39 @@ 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.#binaryVersionsProvider.remove(prev)

// Add the current binary to the cache
if (curr != null) this.#binaryVersionsProvider.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)
}
}
return bins
async setCurrentBinary (name: string): Promise<void> {
await this.#settings.updateSettings({ flowCommand: name })
}

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.#binaryVersionsProvider.getVersions()
}

async setCurrentBinary (name: string): Promise<void> {
await this.#settings.updateSettings({ flowCommand: name })
get binaryVersions$ (): Observable<CliBinary[]> {
return this.#binaryVersionsProvider.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.#binaryVersionsProvider.refresh()
}
}
Loading