Skip to content

Commit

Permalink
add profile cp command (#304)
Browse files Browse the repository at this point in the history
fixes #231
Roy Razon authored Oct 26, 2023
1 parent a5a100b commit 65eae06
Showing 11 changed files with 290 additions and 159 deletions.
2 changes: 1 addition & 1 deletion packages/cli-common/src/lib/text.ts
Original file line number Diff line number Diff line change
@@ -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<Config, 'bin'>, ...args: string[]) => code(`${bin} ${args.join(' ')}`)

91 changes: 3 additions & 88 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
}) => Promise<`${string}://${string}`>

const chooseLocation: Record<LocationType, LocationFactory> = {
local: async ({ profileAlias }: { profileAlias: string }) => `local://${profileAlias}`,
s3: async ({ profileAlias, driver, driverFlags }: {
profileAlias: string
driver: DriverName
driverFlags: Record<string, unknown>
}) => {
// 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<string, unknown>
) => {
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<string, unknown>
}) => {
// 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<string, unknown>,
) => 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<Record<string, unknown>>(await driverStatic.questions())
const driverFlags = await driverStatic.flagsFromAnswers(driverAnswers) as Record<string, unknown>

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',
102 changes: 102 additions & 0 deletions packages/cli/src/commands/profile/cp.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CopyProfile> {
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<FsType>({
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<unknown> {
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 }
}
}
6 changes: 3 additions & 3 deletions packages/cli/src/commands/profile/create.ts
Original file line number Diff line number Diff line change
@@ -19,18 +19,18 @@ export default class CreateProfile extends ProfileCommand<typeof CreateProfile>
...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,
}),
}
6 changes: 3 additions & 3 deletions packages/cli/src/commands/profile/import.ts
Original file line number Diff line number Diff line change
@@ -20,18 +20,18 @@ export default class ImportProfile extends BaseCommand<typeof ImportProfile> {

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,
}),
}
8 changes: 5 additions & 3 deletions packages/cli/src/commands/profile/ls.ts
Original file line number Diff line number Diff line change
@@ -5,17 +5,19 @@ import ProfileCommand from '../../profile-command'
export default class ListProfile extends ProfileCommand<typeof ListProfile> {
static description = 'Lists profiles'

static strict = false

static enableJsonFlag = true

async run(): Promise<unknown> {
const currentProfile = await this.profileConfig.current()
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',
14 changes: 6 additions & 8 deletions packages/cli/src/commands/profile/use.ts
Original file line number Diff line number Diff line change
@@ -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<typeof UseProfile> {
export default class UseProfile extends BaseCommand<typeof UseProfile> {
static description = 'Set current profile'

static args = {
@@ -12,14 +13,11 @@ export default class UseProfile extends ProfileCommand<typeof UseProfile> {
}),
}

static strict = false

static enableJsonFlag = true

async run(): Promise<unknown> {
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
}
}
88 changes: 86 additions & 2 deletions packages/cli/src/fs.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }
}) => Promise<`${string}://${string}`>

export const chooseFs: Record<FsType, FsChooser> = {
local: async ({ profileAlias }: { profileAlias: string }) => `local://${profileAlias}`,
s3: async ({ profileAlias, driver }: {
profileAlias: string
driver?: { name: DriverName; flags: Record<string, unknown> }
}) => {
// 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<string, unknown>
) => {
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<string, unknown> }
}) => {
// 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<string, unknown>,
) => gsDefaultBucketName({ profileAlias, project: answers.project as string }),
},
])

return `gs://${bucket}?project=${project}`
},
}
10 changes: 5 additions & 5 deletions packages/cli/src/profile-command.ts
Original file line number Diff line number Diff line change
@@ -56,14 +56,14 @@ abstract class ProfileCommand<T extends typeof Command> extends BaseCommand<T> {
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
119 changes: 76 additions & 43 deletions packages/core/src/profile/config.ts
Original file line number Diff line number Diff line change
@@ -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<GetResult>
async function get(alias: string, opts: { throwOnNotFound: false }): Promise<GetResult>
async function get(alias: string, opts: { throwOnNotFound: true }): Promise<GetResult | undefined>
async function get(alias: string, opts?: { throwOnNotFound: boolean }): Promise<GetResult | undefined> {
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<Profile, 'id'>, init: (store: ProfileStore) => Promise<void>) => {
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<ProfileListing[]> {
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<Profile, 'id'>, init: (store: ProfileStore) => Promise<void>) {
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,
}
}

3 changes: 0 additions & 3 deletions packages/core/src/profile/store.ts
Original file line number Diff line number Diff line change
@@ -19,9 +19,6 @@ export const profileStore = (store: Store) => {
info: async () => await ref.readJsonOrThrow<Profile>('info.json'),
defaultFlags: async<T>(driver: string) => {
const profile = await ref.readJSON<T>(`${driver}-defaults.json`)
if (!profile) {
return {}
}
return profile ?? {}
},
async updateDriver(driver: string) {

0 comments on commit 65eae06

Please sign in to comment.