From 7ad87dd61348fb2c8bba071822155834cd9477b5 Mon Sep 17 00:00:00 2001 From: Roy Razon Date: Mon, 4 Dec 2023 16:06:03 +0200 Subject: [PATCH] add option to specify a URL in the `--profile` flag --- .../cli-common/src/commands/base-command.ts | 12 +- packages/cli/src/commands/profile/current.ts | 10 +- packages/cli/src/commands/profile/import.ts | 17 +- packages/cli/src/commands/profile/ls.ts | 20 ++- packages/cli/src/commands/profile/rm.ts | 14 +- packages/cli/src/profile-command.ts | 66 ++++--- packages/core/src/ci-providers/common.ts | 10 +- packages/core/src/index.ts | 3 +- packages/core/src/profile/config.ts | 169 +++++++++++------- packages/core/src/url.ts | 7 + 10 files changed, 204 insertions(+), 124 deletions(-) create mode 100644 packages/core/src/url.ts diff --git a/packages/cli-common/src/commands/base-command.ts b/packages/cli-common/src/commands/base-command.ts index 038713b4..20b3244b 100644 --- a/packages/cli-common/src/commands/base-command.ts +++ b/packages/cli-common/src/commands/base-command.ts @@ -13,6 +13,13 @@ export type Args = Interfaces.InferredArgs const argsFromRaw = (raw: ParsingToken[]) => raw.filter(arg => arg.type === 'arg').map(arg => arg.input).filter(Boolean) +const jsonFlags = { + json: Flags.boolean({ + description: 'Format output as JSON', + helpGroup: 'GLOBAL', + }), +} as const + abstract class BaseCommand extends Command { static baseFlags = { 'log-level': Flags.custom({ @@ -86,7 +93,10 @@ abstract class BaseCommand extends Comm await super.init() const { args, flags, raw } = await this.parse({ flags: this.ctor.flags, - baseFlags: super.ctor.baseFlags, + baseFlags: { + ...this.ctor.baseFlags, + ...this.ctor.enableJsonFlag ? jsonFlags : {}, + }, args: this.ctor.args, strict: false, }) diff --git a/packages/cli/src/commands/profile/current.ts b/packages/cli/src/commands/profile/current.ts index 8baaad9f..3a8e4ad9 100644 --- a/packages/cli/src/commands/profile/current.ts +++ b/packages/cli/src/commands/profile/current.ts @@ -14,13 +14,7 @@ export default class CurrentProfile extends ProfileCommand { - const profiles = new Set((await profileConfig.list()).map(l => l.alias)) +const defaultAlias = async (aliases: string[]) => { + const profiles = new Set(aliases) return find( (alias: string) => !profiles.has(alias), map(suffix => (suffix ? `${DEFAULT_ALIAS_PREFIX}${suffix + 1}` : DEFAULT_ALIAS_PREFIX), range()), @@ -38,14 +38,11 @@ export default class ImportProfile extends BaseCommand { async run(): Promise { const profileConfig = loadProfileConfig(this.config) - const alias = this.flags.name ?? await defaultAlias(profileConfig) - - const { info } = await profileConfig.importExisting(alias, this.args.location) - onProfileChange(info, alias, this.args.location) - if (this.flags.use) { - await profileConfig.setCurrent(alias) - } + const aliases = Object.keys((await profileConfig.list()).profiles) + const alias = this.flags.name ?? await defaultAlias(aliases) + const { info } = await profileConfig.importExisting(alias, this.args.location, this.flags.use) + onProfileChange(info, fsTypeFromUrl(this.args.location)) text.success(`Profile ${text.code(info.id)} imported successfully as ${text.code(alias)} 👍`) } } diff --git a/packages/cli/src/commands/profile/ls.ts b/packages/cli/src/commands/profile/ls.ts index f78ae26a..38a7d937 100644 --- a/packages/cli/src/commands/profile/ls.ts +++ b/packages/cli/src/commands/profile/ls.ts @@ -1,4 +1,5 @@ -import { ux } from '@oclif/core' +import { Flags, ux } from '@oclif/core' +import { tableFlags } from '@preevy/cli-common' import ProfileCommand from '../../profile-command' // eslint-disable-next-line no-use-before-define @@ -7,21 +8,22 @@ export default class ListProfile extends ProfileCommand { static enableJsonFlag = true + static flags = { + ...tableFlags, + json: Flags.boolean({}), + } + async run(): Promise { - const currentProfile = await this.profileConfig.current() - const profiles = await this.profileConfig.list() + const { profiles, current } = await this.profileConfig.list() if (this.flags.json) { - return { - profiles: Object.fromEntries(profiles.map(({ alias, ...rest }) => [alias, rest])), - current: currentProfile?.alias, - } + return { profiles, current } } - ux.table(profiles, { + ux.table(Object.values(profiles), { alias: { header: 'Alias', - get: ({ alias }) => `${alias}${alias === currentProfile?.alias ? ' *' : ''}`, + get: ({ alias }) => `${alias}${alias === current ? ' *' : ''}`, }, id: { header: 'Id', diff --git a/packages/cli/src/commands/profile/rm.ts b/packages/cli/src/commands/profile/rm.ts index 9bc44fe3..c80d8d0d 100644 --- a/packages/cli/src/commands/profile/rm.ts +++ b/packages/cli/src/commands/profile/rm.ts @@ -1,4 +1,4 @@ -import { Args, ux } from '@oclif/core' +import { Args, Flags, ux } from '@oclif/core' import { text } from '@preevy/cli-common' import ProfileCommand from '../../profile-command' @@ -6,6 +6,13 @@ import ProfileCommand from '../../profile-command' export default class RemoveProfile extends ProfileCommand { static description = 'Remove a profile' + static flags = { + force: Flags.boolean({ + description: 'Do not error if the profile is not found', + default: false, + }), + } + static args = { name: Args.string({ description: 'name of the profile to remove', @@ -19,8 +26,9 @@ export default class RemoveProfile extends ProfileCommand async run(): Promise { const alias = this.args.name - await this.profileConfig.delete(alias) - ux.info(text.success(`Profile ${text.code(alias)} removed.`)) + if (await this.profileConfig.delete(alias, { throwOnNotFound: !this.flags.force })) { + ux.info(text.success(`Profile ${text.code(alias)} removed.`)) + } return undefined } } diff --git a/packages/cli/src/profile-command.ts b/packages/cli/src/profile-command.ts index b9db203b..7d033b5d 100644 --- a/packages/cli/src/profile-command.ts +++ b/packages/cli/src/profile-command.ts @@ -1,10 +1,13 @@ import path from 'path' import { Command, Flags, Interfaces } from '@oclif/core' -import { LocalProfilesConfig, Profile, Store, detectCiProvider, fsTypeFromUrl, localProfilesConfig, telemetryEmitter } from '@preevy/core' +import { + tryParseUrl, LocalProfilesConfig, Profile, Store, detectCiProvider, fsTypeFromUrl, + localProfilesConfig, telemetryEmitter, LocalProfilesConfigGetResult, +} from '@preevy/core' import { BaseCommand, text } from '@preevy/cli-common' import { fsFromUrl } from './fs' -export const onProfileChange = (profile: Profile, alias: string, location: string) => { +export const onProfileChange = (profile: Profile, profileStoreType: string) => { const ciProvider = detectCiProvider() if (ciProvider) { telemetryEmitter().identify(`ci_${ciProvider.id ?? 'unknown'}_${profile.id}`, { @@ -17,7 +20,7 @@ export const onProfileChange = (profile: Profile, alias: string, location: strin profile_driver: profile.driver, profile_id: profile.id, name: profile.id, - profile_store_type: fsTypeFromUrl(location), + profile_store_type: profileStoreType, } ) } @@ -31,6 +34,40 @@ export const loadProfileConfig = ({ dataDir }: { dataDir: string }): LocalProfil export type Flags = Interfaces.InferredFlags export type Args = Interfaces.InferredArgs +const findAvailableProfileAlias = ( + { existing, prefix }: { existing: Set; prefix: string }, + index = 0, +): string => { + const candidate = [prefix, index].filter(Boolean).join('-') + return existing.has(candidate) ? findAvailableProfileAlias({ existing, prefix }, index + 1) : candidate +} + +const findProfile = async ( + { profileConfig, flags: { profile: profileFlag } }: { + profileConfig: LocalProfilesConfig + flags: { profile?: string } + }, +): Promise => { + const profileUrl = tryParseUrl(profileFlag || '') + if (!profileUrl) { + return await profileConfig.get(profileFlag) + } + + const { profiles } = await profileConfig.list() + + const found = Object.values(profiles).find(p => p.location === profileFlag) + if (found) { + return await profileConfig.get(found.alias) + } + + const newAlias = findAvailableProfileAlias({ + existing: new Set(Object.keys(profiles)), + prefix: profileUrl.hostname, + }) + + return await profileConfig.importExisting(newAlias, profileUrl.toString()) +} + abstract class ProfileCommand extends BaseCommand { static baseFlags = { ...BaseCommand.baseFlags, @@ -45,25 +82,14 @@ abstract class ProfileCommand extends BaseCommand { public async init(): Promise { await super.init() - const { profileConfig } = this - let profileAlias = this.flags.profile - if (!profileAlias) { - const currentProfile = await profileConfig.current() - if (currentProfile) { - profileAlias = currentProfile.alias - } - } - if (!profileAlias) { + const { profileConfig, flags } = this + const profile = await findProfile({ profileConfig, flags }) + if (!profile) { return } - const currentProfileConfig = await profileConfig.get(profileAlias) - if (!currentProfileConfig) { - return - } - - this.#profile = currentProfileConfig.info - this.#store = currentProfileConfig.store - onProfileChange(currentProfileConfig.info, profileAlias, currentProfileConfig.location) + this.#profile = profile.info + this.#store = profile.store + onProfileChange(profile.info, fsTypeFromUrl(profile.location)) } #profileConfig: LocalProfilesConfig | undefined diff --git a/packages/core/src/ci-providers/common.ts b/packages/core/src/ci-providers/common.ts index 9dda6a66..49cbb6a9 100644 --- a/packages/core/src/ci-providers/common.ts +++ b/packages/core/src/ci-providers/common.ts @@ -1,17 +1,11 @@ +import { tryParseUrl } from '../url' + export const nanToUndefined = (value: number) => (Number.isNaN(value) ? undefined : value) export const stringOrUndefinedToNumber = ( value: string | undefined ): number | undefined => (value === undefined ? undefined : nanToUndefined(Number(value))) -const tryParseUrl = (s: string) => { - try { - return new URL(s) - } catch (e) { - return undefined - } -} - export const extractPrNumberFromUrlPath = (s: string | undefined) => { if (!s) { return undefined diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0efedb33..f60c9fbd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,7 +20,7 @@ export { machineStatusNodeExporterCommand, ensureMachine, } from './driver' -export { profileStore, Profile, ProfileStore, link, Org } from './profile' +export { profileStore, Profile, ProfileStore, link, Org, LocalProfilesConfigGetResult } from './profile' export { telemetryEmitter, registerEmitter, wireProcessExit, createTelemetryEmitter, machineId } from './telemetry' export { fsTypeFromUrl, Store, VirtualFS, localFsFromUrl, localFs } from './store' export { localComposeClient, ComposeModel, resolveComposeFiles, getExposedTcpServicePorts, fetchRemoteUserModel as remoteUserModel, NoComposeFilesError, addScriptInjectionsToServices as addScriptInjectionsToModel } from './compose' @@ -57,6 +57,7 @@ export { getTunnelNamesToServicePorts, Connection as SshConnection, } from './tunneling' +export { tryParseUrl } from './url' export { TunnelOpts } from './ssh' export { Spinner } from './spinner' export { generateBasicAuthCredentials as getUserCredentials, jwtGenerator, jwkThumbprint, jwkThumbprintUri, parseKey } from './credentials' diff --git a/packages/core/src/profile/config.ts b/packages/core/src/profile/config.ts index 55687e8d..eb1c4cb0 100644 --- a/packages/core/src/profile/config.ts +++ b/packages/core/src/profile/config.ts @@ -1,12 +1,12 @@ import path from 'path' import { rimraf } from 'rimraf' -import { isEmpty } from 'lodash' +import { isEmpty, mapValues } from 'lodash' import { localFs } from '../store/fs/local' import { Store, VirtualFS, store, tarSnapshot } from '../store' import { ProfileStore, profileStore } from './store' import { Profile } from './profile' -type ProfileListing = { +type ProfileListEntry = { alias: string id: string location: string @@ -14,73 +14,111 @@ type ProfileListing = { type ProfileList = { current: string | undefined - profiles: Record> + profiles: Record } -const profileListFileName = 'profileList.json' +type PersistedProfileList = { + current: string | undefined + profiles: Record> +} + +type GetResult = { + location: string + info: Profile + store: Store + alias: string +} + +export type LocalProfilesConfigGetResult = GetResult + +const listPersistence = ({ localDir }: { localDir: string }) => { + const profileListFileName = 'profileList.json' + const localStore = localFs(localDir) + + return { + read: async (): Promise => { + const readStr = await localStore.read(profileListFileName) + if (!readStr) { + return { current: undefined, profiles: {} } + } + const { current, profiles } = JSON.parse(readStr.toString()) as PersistedProfileList + return { + current, + profiles: mapValues(profiles, (v, alias) => ({ ...v, alias })), + } + }, + write: async ({ profiles, current }: ProfileList): Promise => { + const written: PersistedProfileList = { + current, + profiles: mapValues(profiles, ({ id, location }) => ({ id, location })), + } + await localStore.write(profileListFileName, JSON.stringify(written)) + }, + } +} export const localProfilesConfig = ( localDir: string, fsFromUrl: (url: string, baseDir: string) => Promise, ) => { - const localStore = localFs(localDir) const localProfilesDir = path.join(localDir, 'profiles') const storeFromUrl = async ( url: string, ) => store(async dir => await tarSnapshot(await fsFromUrl(url, localProfilesDir), dir)) + const listP = listPersistence({ localDir }) - async function readProfileList(): Promise { - const data = await localStore.read(profileListFileName) - if (!data) { - const initData = { current: undefined, profiles: {} } - await localStore.write(profileListFileName, JSON.stringify(initData)) - return initData - } - return JSON.parse(data.toString()) - } - - type GetResult = { - location: string - info: Profile - store: Store - } - - async function get(alias: string): Promise - async function get(alias: string, opts: { throwOnNotFound: false }): Promise - async function get(alias: string, opts: { throwOnNotFound: true }): Promise - async function get(alias: string, opts?: { throwOnNotFound: boolean }): Promise { - const { profiles } = await readProfileList() - const locationUrl = profiles[alias]?.location - if (!locationUrl) { + async function get(alias: string | undefined): Promise + async function get(alias: string | undefined, opts: { throwOnNotFound: false }): Promise + async function get(alias: string | undefined, opts: { throwOnNotFound: true }): Promise + async function get(alias: string | undefined, opts?: { throwOnNotFound: boolean }): Promise { + const throwOrUndefined = () => { if (opts?.throwOnNotFound) { throw new Error(`Profile ${alias} not found`) } return undefined } + + const { profiles, current } = await listP.read() + const aliasToGet = alias ?? current + if (!aliasToGet) { + return throwOrUndefined() + } + const locationUrl = profiles[aliasToGet]?.location + if (!locationUrl) { + return throwOrUndefined() + } const tarSnapshotStore = await storeFromUrl(locationUrl) const profileInfo = await profileStore(tarSnapshotStore).info() return { location: locationUrl, info: profileInfo, store: tarSnapshotStore, + alias: aliasToGet, } } - const create = async (alias: string, location: string, profile: Omit, init: (store: ProfileStore) => Promise) => { - const list = await readProfileList() - if (list.profiles[alias]) { + const create = async ( + alias: string, + location: string, + profile: Omit, + init: (store: ProfileStore) => Promise, + makeCurrent = false, + ) => { + const { profiles, current } = await listP.read() + if (profiles[alias]) { throw new Error(`Profile ${alias} already exists`) } const id = `${alias}-${Math.random().toString(36).substring(2, 9)}` const tar = await storeFromUrl(location) const pStore = profileStore(tar) await pStore.init({ id, ...profile }) - list.profiles[alias] = { + profiles[alias] = { + alias, id, location, } await init(pStore) - await localStore.write(profileListFileName, JSON.stringify(list)) + await listP.write({ profiles, current: makeCurrent ? alias : current }) return { info: { id, @@ -90,7 +128,11 @@ export const localProfilesConfig = ( } } - const copy = async (source: { location: string }, target: { alias: string; location: string }, drivers: string[]) => { + const copy = async ( + source: { location: string }, + target: { alias: string; location: string }, + drivers: string[], + ) => { const sourceStore = await storeFromUrl(source.location) const sourceProfileStore = profileStore(sourceStore) const { driver } = await sourceProfileStore.info() @@ -109,61 +151,60 @@ export const localProfilesConfig = ( } return { - async current() { - const { profiles, current: currentAlias } = await readProfileList() - const current = currentAlias && profiles[currentAlias] - if (!current) { - return undefined - } - return { - alias: currentAlias, - id: current.id, - location: current.location, - } + async current(): Promise { + const { profiles, current: currentAlias } = await listP.read() + return currentAlias ? profiles[currentAlias] : undefined }, async setCurrent(alias: string) { - const list = await readProfileList() + const list = await listP.read() if (!list.profiles[alias]) { throw new Error(`Profile ${alias} doesn't exists`) } list.current = alias - await localStore.write(profileListFileName, JSON.stringify(list)) + await listP.write(list) }, - async list(): Promise { - return Object.entries((await readProfileList()).profiles).map(([alias, profile]) => ({ alias, ...profile })) + async list(): Promise { + return await listP.read() }, get, - async delete(alias: string) { - const list = await readProfileList() - const listing = list.profiles[alias] - if (!listing) { - throw new Error(`Profile ${alias} does not exist`) + async delete(alias: string, opts: { throwOnNotFound?: boolean } = {}) { + const list = await listP.read() + const entry = list.profiles[alias] + if (!entry) { + if (opts.throwOnNotFound) { + throw new Error(`Profile ${alias} does not exist`) + } + return false } delete list.profiles[alias] if (list.current === alias) { list.current = undefined } - await localStore.write(profileListFileName, JSON.stringify(list)) - if (listing.location.startsWith('local://')) { + await listP.write(list) + if (entry.location.startsWith('local://')) { await rimraf(path.join(localProfilesDir, alias)) } + return true }, - async importExisting(alias: string, location: string) { - const list = await readProfileList() - if (list.profiles[alias]) { + async importExisting(alias: string, fromLocation: string, makeCurrent = false): Promise { + const { profiles, current } = await listP.read() + if (profiles[alias]) { throw new Error(`Profile ${alias} already exists`) } - const tarSnapshotStore = await storeFromUrl(location) + const tarSnapshotStore = await storeFromUrl(fromLocation) const info = await profileStore(tarSnapshotStore).info() - list.profiles[alias] = { + const newProfile = { id: info.id, - location, + alias, + location: fromLocation, } - list.current = alias - await localStore.write(profileListFileName, JSON.stringify(list)) + profiles[alias] = newProfile + await listP.write({ profiles, current: makeCurrent ? alias : current }) return { + location: fromLocation, info, store: tarSnapshotStore, + alias, } }, create, diff --git a/packages/core/src/url.ts b/packages/core/src/url.ts new file mode 100644 index 00000000..35d6f38f --- /dev/null +++ b/packages/core/src/url.ts @@ -0,0 +1,7 @@ +export const tryParseUrl = (s: string) => { + try { + return new URL(s) + } catch (e) { + return undefined + } +}