From 65eae06eaf9d149db301201352b8f184a33549e0 Mon Sep 17 00:00:00 2001 From: Roy Razon Date: Thu, 26 Oct 2023 14:03:04 +0300 Subject: [PATCH] add profile cp command (#304) fixes #231 --- packages/cli-common/src/lib/text.ts | 2 +- packages/cli/src/commands/init.ts | 91 +-------------- packages/cli/src/commands/profile/cp.ts | 102 +++++++++++++++++ packages/cli/src/commands/profile/create.ts | 6 +- packages/cli/src/commands/profile/import.ts | 6 +- packages/cli/src/commands/profile/ls.ts | 8 +- packages/cli/src/commands/profile/use.ts | 14 +-- packages/cli/src/fs.ts | 88 ++++++++++++++- packages/cli/src/profile-command.ts | 10 +- packages/core/src/profile/config.ts | 119 +++++++++++++------- packages/core/src/profile/store.ts | 3 - 11 files changed, 290 insertions(+), 159 deletions(-) create mode 100644 packages/cli/src/commands/profile/cp.ts diff --git a/packages/cli-common/src/lib/text.ts b/packages/cli-common/src/lib/text.ts index d362cb69..544a58fd 100644 --- a/packages/cli-common/src/lib/text.ts +++ b/packages/cli-common/src/lib/text.ts @@ -4,7 +4,7 @@ import { EOL } from 'os' export const code = (c: string) => chalk.bold(c) -export const codeList = (c: string[]) => c.map(code).join(', ') +export const codeList = (c: string[] | readonly string[]) => c.map(code).join(', ') export const command = ({ bin }: Pick, ...args: string[]) => code(`${bin} ${args.join(' ')}`) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 781afb1f..7bbb6865 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,14 +1,12 @@ import { Flags, Args, ux } from '@oclif/core' import inquirer from 'inquirer' import confirm from '@inquirer/confirm' -import { defaultBucketName as gsDefaultBucketName, defaultProjectId as defaultGceProjectId } from '@preevy/driver-gce' -import { defaultBucketName as s3DefaultBucketName, AWS_REGIONS, awsUtils } from '@preevy/driver-lightsail' import { BaseCommand, text } from '@preevy/cli-common' import { EOL } from 'os' import { Flag } from '@oclif/core/lib/interfaces' import { DriverName, formatDriverFlagsToArgs, machineDrivers } from '../drivers' import { loadProfileConfig } from '../profile-command' -import ambientAwsAccountId = awsUtils.ambientAccountId +import { chooseFs, chooseFsType } from '../fs' const chooseDriver = async () => ( await inquirer.prompt<{ driver: DriverName }>([ @@ -26,89 +24,6 @@ const chooseDriver = async () => ( ]) ).driver -const locationTypes = ['local', 's3', 'gs'] as const -type LocationType = typeof locationTypes[number] - -const chooseLocationType = async () => ( - await inquirer.prompt<{ locationType: LocationType }>([ - { - type: 'list', - name: 'locationType', - message: 'Where do you want to store the profile?', - default: 'local', - choices: [ - { value: 'local', name: 'local file' }, - { value: 's3', name: 'AWS S3' }, - { value: 'gs', name: 'Google Cloud Storage' }, - ], - }, - ]) -).locationType - -type LocationFactory = (opts: { - profileAlias: string - driver: DriverName - driverFlags: Record -}) => Promise<`${string}://${string}`> - -const chooseLocation: Record = { - local: async ({ profileAlias }: { profileAlias: string }) => `local://${profileAlias}`, - s3: async ({ profileAlias, driver, driverFlags }: { - profileAlias: string - driver: DriverName - driverFlags: Record - }) => { - // eslint-disable-next-line no-use-before-define - const { region, bucket } = await inquirer.prompt<{ region: string; bucket: string }>([ - { - type: 'list', - name: 'region', - message: 'S3 bucket region', - choices: AWS_REGIONS, - default: driver === 'lightsail' ? driverFlags.region as string : 'us-east-1', - }, - { - type: 'input', - name: 'bucket', - message: 'Bucket name', - default: async ( - answers: Record - ) => { - const accountId = await ambientAwsAccountId(answers.region as string) - return accountId ? s3DefaultBucketName({ profileAlias, accountId }) : undefined - }, - }, - ]) - - return `s3://${bucket}?region=${region}` - }, - gs: async ({ profileAlias, driver, driverFlags }: { - profileAlias: string - driver: DriverName - driverFlags: Record - }) => { - // eslint-disable-next-line no-use-before-define - const { project, bucket } = await inquirer.prompt<{ project: string; bucket: string }>([ - { - type: 'input', - name: 'project', - message: 'Google Cloud project', - default: driver === 'gce' ? driverFlags['project-id'] : defaultGceProjectId(), - }, - { - type: 'input', - name: 'bucket', - message: 'Bucket name', - default: ( - answers: Record, - ) => gsDefaultBucketName({ profileAlias, project: answers.project as string }), - }, - ]) - - return `gs://${bucket}?project=${project}` - }, -} - export default class Init extends BaseCommand { static description = 'Initialize or import a new profile' @@ -154,9 +69,9 @@ export default class Init extends BaseCommand { const driverAnswers = await inquirer.prompt>(await driverStatic.questions()) const driverFlags = await driverStatic.flagsFromAnswers(driverAnswers) as Record - const locationType = await chooseLocationType() + const locationType = await chooseFsType() - const location = await chooseLocation[locationType]({ profileAlias, driver, driverFlags }) + const location = await chooseFs[locationType]({ profileAlias, driver: { name: driver, flags: driverFlags } }) await this.config.runCommand('profile:create', [ '--use', diff --git a/packages/cli/src/commands/profile/cp.ts b/packages/cli/src/commands/profile/cp.ts new file mode 100644 index 00000000..0ca87f41 --- /dev/null +++ b/packages/cli/src/commands/profile/cp.ts @@ -0,0 +1,102 @@ +import { Flags, ux } from '@oclif/core' +import inquirer from 'inquirer' +import { BaseCommand, text } from '@preevy/cli-common' +import { LocalProfilesConfig } from '@preevy/core' +import { loadProfileConfig } from '../../profile-command' +import { FsType, chooseFs, chooseFsType, fsTypes, isFsType } from '../../fs' +import { machineDrivers } from '../../drivers' + +const validateFsType = (fsType: string) => { + if (!isFsType(fsType)) { + throw new Error(`Unsupported storage type: ${text.code(fsType)}. Supported types: ${text.codeList(fsTypes as readonly string[])}`) + } + return fsType +} + +const chooseTargetAlias = async (defaultAlias: string) => ( + await inquirer.prompt<{ targetAlias: string }>([ + { + type: 'input', + name: 'targetAlias', + message: 'Target profile name', + default: defaultAlias, + }, + ]) +).targetAlias + +// eslint-disable-next-line no-use-before-define +export default class CopyProfile extends BaseCommand { + static description = 'Copy a profile' + + static enableJsonFlag = true + + static flags = { + profile: Flags.string({ + description: 'Source profile name, defaults to the current profile', + required: false, + }), + // eslint-disable-next-line no-restricted-globals + 'target-location': Flags.custom<{ location: string; fsType: FsType }>({ + description: 'Target profile location URL', + required: false, + exclusive: ['target-storage'], + parse: async location => { + let url: URL + try { + url = new URL(location) + } catch (e) { + throw new Error(`Invalid URL: ${text.code(location)}`, { cause: e }) + } + return { location, fsType: validateFsType(url.protocol.replace(':', '')) } + }, + })(), + 'target-storage': Flags.custom({ + description: 'Target profile storage type', + required: false, + options: [...fsTypes], + })(), + 'target-name': Flags.string({ + description: 'Target profile name', + required: false, + }), + use: Flags.boolean({ + description: 'Mark the new profile as the current profile', + required: false, + }), + } + + async source(profileConfig: LocalProfilesConfig): Promise<{ alias: string; location: string }> { + if (this.flags.profile) { + const { location } = await profileConfig.get(this.flags.profile) + return { alias: this.flags.profile, location } + } + const result = await profileConfig.current() + if (!result) { + throw new Error(`No current profile, specify the source alias with ${text.code(`--${CopyProfile.flags.profile.name}`)}`) + } + ux.info(`Copying current profile ${text.code(result.alias)} from ${text.code(result.location)}`) + return result + } + + async target(source: { alias: string }): Promise<{ location: string; alias: string }> { + const { 'target-location': targetLocation, 'target-storage': targetStorage } = this.flags + const fsType = targetLocation?.fsType ?? targetStorage ?? await chooseFsType() + const alias = this.flags['target-name'] ?? await chooseTargetAlias(`${source.alias}-${fsType}`) + return { alias, location: targetLocation?.location ?? await chooseFs[fsType]({ profileAlias: alias }) } + } + + async run(): Promise { + const profileConfig = loadProfileConfig(this.config) + const source = await this.source(profileConfig) + const target = await this.target(source) + await profileConfig.copy(source, target, Object.keys(machineDrivers)) + + ux.info(text.success(`Profile ${text.code(source.alias)} copied to ${text.code(target.location)} as ${text.code(target.alias)}`)) + + if (this.flags.use) { + await profileConfig.setCurrent(target.alias) + } + + return { source, target } + } +} diff --git a/packages/cli/src/commands/profile/create.ts b/packages/cli/src/commands/profile/create.ts index 20b090a1..f60de4b8 100644 --- a/packages/cli/src/commands/profile/create.ts +++ b/packages/cli/src/commands/profile/create.ts @@ -19,18 +19,18 @@ export default class CreateProfile extends ProfileCommand ...machineCreationflagsForAllDrivers, driver: DriverCommand.baseFlags.driver, use: Flags.boolean({ - description: 'use the new profile', + description: 'Mark the new profile as the current profile', required: false, }), } static args = { name: Args.string({ - description: 'name of the new profile', + description: 'Name of the new profile', required: true, }), url: Args.string({ - description: 'url of the new profile store', + description: 'URL of the new profile', required: true, }), } diff --git a/packages/cli/src/commands/profile/import.ts b/packages/cli/src/commands/profile/import.ts index a7d2567d..858dafbf 100644 --- a/packages/cli/src/commands/profile/import.ts +++ b/packages/cli/src/commands/profile/import.ts @@ -20,18 +20,18 @@ export default class ImportProfile extends BaseCommand { static flags = { name: Flags.string({ - description: 'name of the profile', + description: 'Name of the profile', required: false, }), use: Flags.boolean({ - description: 'use the imported profile', + description: 'Mark the new profile as the current profile', required: false, }), } static args = { location: Args.string({ - description: 'location of the profile', + description: 'URL of the profile', required: true, }), } diff --git a/packages/cli/src/commands/profile/ls.ts b/packages/cli/src/commands/profile/ls.ts index 89b4a03f..f78ae26a 100644 --- a/packages/cli/src/commands/profile/ls.ts +++ b/packages/cli/src/commands/profile/ls.ts @@ -5,8 +5,6 @@ import ProfileCommand from '../../profile-command' export default class ListProfile extends ProfileCommand { static description = 'Lists profiles' - static strict = false - static enableJsonFlag = true async run(): Promise { @@ -14,8 +12,12 @@ export default class ListProfile extends ProfileCommand { const profiles = await this.profileConfig.list() if (this.flags.json) { - return profiles + return { + profiles: Object.fromEntries(profiles.map(({ alias, ...rest }) => [alias, rest])), + current: currentProfile?.alias, + } } + ux.table(profiles, { alias: { header: 'Alias', diff --git a/packages/cli/src/commands/profile/use.ts b/packages/cli/src/commands/profile/use.ts index 8eca130d..2ebb8b7b 100644 --- a/packages/cli/src/commands/profile/use.ts +++ b/packages/cli/src/commands/profile/use.ts @@ -1,8 +1,9 @@ import { Args, ux } from '@oclif/core' -import ProfileCommand from '../../profile-command' +import { BaseCommand, text } from '@preevy/cli-common' +import { loadProfileConfig } from '../../profile-command' // eslint-disable-next-line no-use-before-define -export default class UseProfile extends ProfileCommand { +export default class UseProfile extends BaseCommand { static description = 'Set current profile' static args = { @@ -12,14 +13,11 @@ export default class UseProfile extends ProfileCommand { }), } - static strict = false - - static enableJsonFlag = true - async run(): Promise { const alias = this.args.name - await this.profileConfig.setCurrent(alias) - ux.info(`Profile ${alias} is now being used`) + const profileConfig = loadProfileConfig(this.config) + await profileConfig.setCurrent(alias) + ux.info(text.success(`Profile ${text.code(alias)} is now being used`)) return undefined } } diff --git a/packages/cli/src/fs.ts b/packages/cli/src/fs.ts index ec5a1d9a..21413fd3 100644 --- a/packages/cli/src/fs.ts +++ b/packages/cli/src/fs.ts @@ -1,6 +1,9 @@ import { fsTypeFromUrl, localFsFromUrl } from '@preevy/core' -import { googleCloudStorageFs } from '@preevy/driver-gce' -import { s3fs } from '@preevy/driver-lightsail' +import { googleCloudStorageFs, defaultBucketName as gsDefaultBucketName, defaultProjectId as defaultGceProjectId } from '@preevy/driver-gce' +import { s3fs, defaultBucketName as s3DefaultBucketName, AWS_REGIONS, awsUtils } from '@preevy/driver-lightsail' +import inquirer from 'inquirer' +import { DriverName } from './drivers' +import ambientAwsAccountId = awsUtils.ambientAccountId export const fsFromUrl = async (url: string, localBaseDir: string) => { const fsType = fsTypeFromUrl(url) @@ -19,3 +22,84 @@ export const fsFromUrl = async (url: string, localBaseDir: string) => { } throw new Error(`Unsupported URL type: ${fsType}`) } + +export const fsTypes = ['local', 's3', 'gs'] as const +export type FsType = typeof fsTypes[number] +export const isFsType = (s: string): s is FsType => fsTypes.includes(s as FsType) + +export const chooseFsType = async () => ( + await inquirer.prompt<{ locationType: FsType }>([ + { + type: 'list', + name: 'locationType', + message: 'Where do you want to store the profile?', + default: 'local', + choices: [ + { value: 'local', name: 'local file' }, + { value: 's3', name: 'AWS S3' }, + { value: 'gs', name: 'Google Cloud Storage' }, + ], + }, + ]) +).locationType + +export type FsChooser = (opts: { + profileAlias: string + driver?: { name: DriverName; flags: Record } +}) => Promise<`${string}://${string}`> + +export const chooseFs: Record = { + local: async ({ profileAlias }: { profileAlias: string }) => `local://${profileAlias}`, + s3: async ({ profileAlias, driver }: { + profileAlias: string + driver?: { name: DriverName; flags: Record } + }) => { + // eslint-disable-next-line no-use-before-define + const { region, bucket } = await inquirer.prompt<{ region: string; bucket: string }>([ + { + type: 'list', + name: 'region', + message: 'S3 bucket region', + choices: AWS_REGIONS, + default: driver?.name === 'lightsail' ? driver.flags.region as string : 'us-east-1', + }, + { + type: 'input', + name: 'bucket', + message: 'Bucket name', + default: async ( + answers: Record + ) => { + const accountId = await ambientAwsAccountId(answers.region as string) + return accountId ? s3DefaultBucketName({ profileAlias, accountId }) : undefined + }, + }, + ]) + + return `s3://${bucket}?region=${region}` + }, + gs: async ({ profileAlias, driver }: { + profileAlias: string + driver?: { name: DriverName; flags: Record } + }) => { + // eslint-disable-next-line no-use-before-define + const { project, bucket } = await inquirer.prompt<{ project: string; bucket: string }>([ + { + type: 'input', + name: 'project', + message: 'Google Cloud project', + default: driver?.name === 'gce' ? driver.flags['project-id'] : defaultGceProjectId(), + }, + { + type: 'input', + name: 'bucket', + message: 'Bucket name', + default: ( + answers: Record, + ) => gsDefaultBucketName({ profileAlias, project: answers.project as string }), + }, + ]) + + return `gs://${bucket}?project=${project}` + }, +} diff --git a/packages/cli/src/profile-command.ts b/packages/cli/src/profile-command.ts index 614fe9b9..b9db203b 100644 --- a/packages/cli/src/profile-command.ts +++ b/packages/cli/src/profile-command.ts @@ -56,14 +56,14 @@ abstract class ProfileCommand extends BaseCommand { if (!profileAlias) { return } - const currentProfileInfo = await profileConfig.get(profileAlias) - if (!currentProfileInfo) { + const currentProfileConfig = await profileConfig.get(profileAlias) + if (!currentProfileConfig) { return } - this.#profile = currentProfileInfo.info - this.#store = currentProfileInfo.store - onProfileChange(currentProfileInfo.info, profileAlias, currentProfileInfo.location) + this.#profile = currentProfileConfig.info + this.#store = currentProfileConfig.store + onProfileChange(currentProfileConfig.info, profileAlias, currentProfileConfig.location) } #profileConfig: LocalProfilesConfig | undefined diff --git a/packages/core/src/profile/config.ts b/packages/core/src/profile/config.ts index 60b62d13..55687e8d 100644 --- a/packages/core/src/profile/config.ts +++ b/packages/core/src/profile/config.ts @@ -1,7 +1,8 @@ import path from 'path' import { rimraf } from 'rimraf' +import { isEmpty } from 'lodash' import { localFs } from '../store/fs/local' -import { VirtualFS, store, tarSnapshot } from '../store' +import { Store, VirtualFS, store, tarSnapshot } from '../store' import { ProfileStore, profileStore } from './store' import { Profile } from './profile' @@ -24,7 +25,7 @@ export const localProfilesConfig = ( ) => { const localStore = localFs(localDir) const localProfilesDir = path.join(localDir, 'profiles') - const tarSnapshotFromUrl = async ( + const storeFromUrl = async ( url: string, ) => store(async dir => await tarSnapshot(await fsFromUrl(url, localProfilesDir), dir)) @@ -38,6 +39,75 @@ export const localProfilesConfig = ( 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) { + if (opts?.throwOnNotFound) { + throw new Error(`Profile ${alias} not found`) + } + return undefined + } + const tarSnapshotStore = await storeFromUrl(locationUrl) + const profileInfo = await profileStore(tarSnapshotStore).info() + return { + location: locationUrl, + info: profileInfo, + store: tarSnapshotStore, + } + } + + const create = async (alias: string, location: string, profile: Omit, init: (store: ProfileStore) => Promise) => { + const list = await readProfileList() + if (list.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] = { + id, + location, + } + await init(pStore) + await localStore.write(profileListFileName, JSON.stringify(list)) + return { + info: { + id, + ...profile, + }, + store: tar, + } + } + + 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() + await create(target.alias, target.location, { driver }, async pStore => { + await pStore.setTunnelingKey(await sourceProfileStore.getTunnelingKey()) + if (driver) { + await pStore.updateDriver(driver) + } + await Promise.all(drivers.map(async sourceDriver => { + const driverFlags = await sourceProfileStore.defaultFlags(sourceDriver) + if (!isEmpty(driverFlags)) { + await pStore.setDefaultFlags(sourceDriver, driverFlags) + } + })) + }) + } + return { async current() { const { profiles, current: currentAlias } = await readProfileList() @@ -62,23 +132,7 @@ export const localProfilesConfig = ( async list(): Promise { return Object.entries((await readProfileList()).profiles).map(([alias, profile]) => ({ alias, ...profile })) }, - async get(alias: string, opts: { throwOnNotFound: boolean } = { throwOnNotFound: true }) { - const { profiles } = await readProfileList() - const locationUrl = profiles[alias]?.location - if (!locationUrl) { - if (opts.throwOnNotFound) { - throw new Error(`Profile ${alias} not found`) - } - return undefined - } - const tarSnapshotStore = await tarSnapshotFromUrl(locationUrl) - const profileInfo = await profileStore(tarSnapshotStore).info() - return { - location: locationUrl, - info: profileInfo, - store: tarSnapshotStore, - } - }, + get, async delete(alias: string) { const list = await readProfileList() const listing = list.profiles[alias] @@ -99,7 +153,7 @@ export const localProfilesConfig = ( if (list.profiles[alias]) { throw new Error(`Profile ${alias} already exists`) } - const tarSnapshotStore = await tarSnapshotFromUrl(location) + const tarSnapshotStore = await storeFromUrl(location) const info = await profileStore(tarSnapshotStore).info() list.profiles[alias] = { id: info.id, @@ -112,29 +166,8 @@ export const localProfilesConfig = ( store: tarSnapshotStore, } }, - async create(alias: string, location: string, profile: Omit, init: (store: ProfileStore) => Promise) { - const list = await readProfileList() - if (list.profiles[alias]) { - throw new Error(`Profile ${alias} already exists`) - } - const id = `${alias}-${Math.random().toString(36).substring(2, 9)}` - const tar = await tarSnapshotFromUrl(location) - const pStore = profileStore(tar) - await pStore.init({ id, ...profile }) - list.profiles[alias] = { - id, - location, - } - await init(pStore) - await localStore.write(profileListFileName, JSON.stringify(list)) - return { - info: { - id, - ...profile, - }, - store: tar, - } - }, + create, + copy, } } diff --git a/packages/core/src/profile/store.ts b/packages/core/src/profile/store.ts index 48916ae4..13734d10 100644 --- a/packages/core/src/profile/store.ts +++ b/packages/core/src/profile/store.ts @@ -19,9 +19,6 @@ export const profileStore = (store: Store) => { info: async () => await ref.readJsonOrThrow('info.json'), defaultFlags: async(driver: string) => { const profile = await ref.readJSON(`${driver}-defaults.json`) - if (!profile) { - return {} - } return profile ?? {} }, async updateDriver(driver: string) {