Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"devDependencies": {
"@commercelayer/cli-test-utils": "workspace:*",
"@oclif/core": "catalog:",
"@types/chai": "catalog:",
"@types/jsonwebtoken": "^9.0.10",
"@types/mocha": "catalog:",
Expand All @@ -51,11 +52,13 @@
},
"dependencies": {
"@commercelayer/js-auth": "catalog:",
"@oclif/core": "catalog:",
"chalk": "catalog:",
"jsonwebtoken": "^9.0.3",
"update-notifier-cjs": "catalog:"
},
"peerDependencies": {
"@oclif/core": "^5.1.2"
},
"publishConfig": {
"access": "public"
},
Expand Down
168 changes: 168 additions & 0 deletions packages/core/src/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* The base command of the CLI plugins that call the Core API: the flags the
* CLI fills in from the current application, the plugin update check, the
* application kind check, the SDK client options and the common API errors.
*
* Plugins extend it with their own base command; whatever is specific to a
* plugin stays there. It doesn't depend on the SDK: the plugin creates the
* client from clientOptions().
*/
import { Command, Flags, type Interfaces } from '@oclif/core'
import * as clColor from './color'
import * as clOutput from './output'
import * as clToken from './token'
import * as clUpdate from './update'
import * as clUtil from './util'


type CommandError = Interfaces.CommandError

/** What a plugin may change of the base flags */
export type BaseFlagOptions = {
char?: Interfaces.AlphabetLowercase | Interfaces.AlphabetUppercase
description?: string
hidden?: boolean
dependsOn?: string[]
}


/** The organization slug, filled in by the CLI from the current application (or CL_CLI_ORGANIZATION) */
export const organizationFlag = (options: BaseFlagOptions = {}) => Flags.string({
char: 'o',
description: 'the slug of your organization',
required: true,
env: 'CL_CLI_ORGANIZATION',
hidden: true,
...options,
})

/** The API domain, filled in by the CLI from the current application (or CL_CLI_DOMAIN) */
export const domainFlag = (options: BaseFlagOptions = {}) => Flags.string({
char: 'd',
required: false,
hidden: true,
dependsOn: ['organization'],
env: 'CL_CLI_DOMAIN',
...options,
})

/** The access token, filled in by the CLI from the current application (or CL_CLI_ACCESS_TOKEN) */
export const accessTokenFlag = (options: BaseFlagOptions = {}) => Flags.string({
hidden: true,
required: true,
env: 'CL_CLI_ACCESS_TOKEN',
...options,
})


/** The options of a Core API client (SDK), from the command flags */
export type ClientOptions = {
organization: string
domain?: string
accessToken: string
userAgent: string
}

/** What handleApiError needs of an SDK ApiError */
export type ApiErrorLike = {
status?: number
errors?: unknown
first: () => { title?: string; detail?: string } | undefined
}

export type ApiErrorOptions = {
/** The resource type in the messages (e.g. 'tag'): the 404 message names it, the 401 suggestion uses its plural */
resource?: string
/** The ID of the resource the request was about, for the 404 message */
id?: string
/** How the ID is called in the 404 message (default: 'id') */
idLabel?: string
/** The command flags, for the output format of the other errors */
flags?: any
}


const QUIET_FLAGS = ['--blind', '--silent', '--quiet']


export abstract class CLCommand extends Command {

static baseFlags = {
organization: organizationFlag(),
domain: domainFlag(),
accessToken: accessTokenFlag(),
}

/** The application kinds the commands accept, checked on the access token before they run (all when undefined) */
static applicationKinds?: readonly string[]


async init(): Promise<any> {

// Check for plugin updates, only in visible mode
const pjson = this.ctor.plugin?.pjson
if (pjson && !this.argv.some(a => QUIET_FLAGS.includes(a))) clUpdate.checkUpdate(pjson as unknown as clUpdate.Package)

// Application kind check, on the access token the CLI passes
const kinds = (this.ctor as typeof CLCommand).applicationKinds
const atFlag = this.argv.find(a => a.startsWith('--accessToken='))
if (kinds && atFlag) this.checkApplication(atFlag.substring(atFlag.indexOf('=') + 1), kinds)

return await super.init()

}


async catch(error: CommandError): Promise<any> {
// The user quit an interactive prompt
if (error.message?.includes('quit')) this.exit()
else return await super.catch(error)
}


/** Checks that the access token belongs to an application of one of the given kinds */
protected checkApplication(accessToken: string, kinds: readonly string[]): boolean {

let info: clToken.AccessTokenInfo
try {
info = clToken.decodeAccessToken(accessToken)
} catch {
this.error('Invalid access token provided')
}

if (!kinds.includes(info.application.kind))
this.error(`Invalid application kind: ${clColor.msg.error(info.application.kind)}. Application kind must be one of the following: ${clColor.cyanBright(kinds.join(', '))}`)

return true

}


/** The options of the Core API client: organization, domain and access token from the flags, the CLI user agent */
protected clientOptions(flags: { organization?: string; domain?: string; accessToken?: string }): ClientOptions {
return {
organization: flags.organization || '',
domain: flags.domain,
accessToken: flags.accessToken || '',
userAgent: clUtil.userAgent(this.config),
}
}


/**
* Stops with the message of a Core API error: an unauthorized request
* suggests to log in, a missing resource names it, any other error is
* printed as the API returned it.
*/
protected handleApiError(error: ApiErrorLike, { resource, id, idLabel = 'id', flags }: ApiErrorOptions = {}): never {
if (error.status === 401) {
const err = error.first()
this.error(clColor.msg.error(`${err?.title}: ${err?.detail}`), {
suggestions: [`Execute login to get access to the organization's ${resource ? `${resource}s` : 'resources'}`],
})
}
if ((error.status === 404) && resource) this.error(`Unable to find ${resource}${id ? ` with ${idLabel} ${clColor.msg.error(id)}` : ''}`)
this.error(clOutput.formatError(error, flags))
}

}
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@

export * as clApi from './api'

export * as clApplication from './application'
export type { ApiErrorLike, ApiErrorOptions, BaseFlagOptions, ClientOptions } from './base'
export { accessTokenFlag, CLCommand, domainFlag, organizationFlag } from './base'
export * as clCommand from './command'
export { default as clConfig } from './config'

Expand Down
86 changes: 86 additions & 0 deletions packages/core/test/unit/base.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { accessToken } from '@commercelayer/cli-test-utils'
import { expect } from 'chai'
import { CLCommand } from '../../src/base'

class TestCommand extends CLCommand {
static applicationKinds = ['integration']
async run(): Promise<void> {}
}

const config = { name: '@commercelayer/cli-plugin-test', version: '1.2.3' } as any
const command = () => new TestCommand([], config) as any

/** The message of the error a call stops with */
const stopsWith = (fn: () => unknown): string => {
try {
fn()
} catch (error: any) {
return error.message
}
throw new Error('expected an error')
}

const apiError = (status: number, errors = [{ title: 'Unauthorized', detail: 'Invalid token' }]) => ({ status, errors, first: () => errors[0] })

describe('CLCommand', () => {
it('has the flags the CLI fills in from the current application', () => {
const { organization, domain, accessToken } = CLCommand.baseFlags
expect(organization).to.include({ char: 'o', required: true, hidden: true, env: 'CL_CLI_ORGANIZATION' })
expect(domain).to.include({ char: 'd', required: false, hidden: true, env: 'CL_CLI_DOMAIN' })
expect(accessToken).to.include({ required: true, hidden: true, env: 'CL_CLI_ACCESS_TOKEN' })
})

it('builds the client options from the flags, with the CLI user agent', () => {
const options = command().clientOptions({ organization: 'org', domain: 'commercelayer.co', accessToken: 'token' })
expect(options).to.deep.equal({ organization: 'org', domain: 'commercelayer.co', accessToken: 'token', userAgent: 'CLI-test/1.2.3' })
})

describe('checkApplication', () => {
it('accepts a token of the given kinds', () => {
expect(command().checkApplication(accessToken({ kind: 'integration' }), ['integration'])).to.equal(true)
})
it('refuses a token of another kind', () => {
expect(stopsWith(() => command().checkApplication(accessToken({ kind: 'sales_channel' }), ['integration']))).to.match(
/Invalid application kind: .*sales_channel.*integration/,
)
})
it('refuses an invalid token', () => {
expect(stopsWith(() => command().checkApplication('not-a-token', ['integration']))).to.equal('Invalid access token provided')
})
})

describe('init', () => {
it('checks the application kind of the access token passed by the CLI', async () => {
const cmd = new TestCommand(['--accessToken=' + accessToken({ kind: 'sales_channel' }), '--blind'], config) as any
const message = await cmd.init().then(
() => '',
(error: Error) => error.message,
)
expect(message).to.match(/Invalid application kind/)
})
})

describe('handleApiError', () => {
it('suggests to log in on an unauthorized request', () => {
try {
command().handleApiError(apiError(401), { resource: 'tag' })
} catch (error: any) {
expect(error.message).to.contain('Unauthorized: Invalid token')
expect(error.suggestions).to.deep.equal(["Execute login to get access to the organization's tags"])
return
}
throw new Error('expected an error')
})
it('names a missing resource', () => {
expect(stopsWith(() => command().handleApiError(apiError(404), { resource: 'tag', id: 'abc', idLabel: 'ID or name' }))).to.match(
/^Unable to find tag with ID or name .*abc/,
)
expect(stopsWith(() => command().handleApiError(apiError(404), { resource: 'webhook' }))).to.equal('Unable to find webhook')
})
it('prints any other error as the API returned it', () => {
const errors = [{ title: 'Unprocessable', detail: 'name is invalid' }]
expect(stopsWith(() => command().handleApiError(apiError(422, errors)))).to.contain('name is invalid')
expect(stopsWith(() => command().handleApiError(apiError(404, errors)))).to.contain('name is invalid')
})
})
})
68 changes: 9 additions & 59 deletions plugins/checkout/src/base.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,27 @@
import { clColor, clOutput, clToken, clUpdate, clUtil } from '@commercelayer/cli-core'
import { accessTokenFlag, CLCommand, clColor, clToken } from '@commercelayer/cli-core'
import commercelayer, { type CommerceLayerClient, CommerceLayerStatic } from '@commercelayer/sdk'
import type { Interfaces } from '@oclif/core'
import { Args, Command, Flags } from '@oclif/core'
import { Args, Flags } from '@oclif/core'

type CommandError = Interfaces.CommandError


const pkg: clUpdate.Package = require('../package.json')


const REQUIRED_APP_KIND = 'sales_channel'


export default abstract class extends Command {
export default abstract class extends CLCommand {

static baseFlags = {
organization: Flags.string({
char: 'o',
description: 'the slug of your organization',
required: true,
env: 'CL_CLI_ORGANIZATION',
hidden: true
}),
domain: Flags.string({
char: 'd',
required: false,
hidden: true,
dependsOn: ['organization'],
env: 'CL_CLI_DOMAIN'
}),
accessToken: Flags.string({
...CLCommand.baseFlags,
accessToken: accessTokenFlag({
char: 'a',
description: 'custom access token to use instead of the one used for login',
hidden: false,
required: true,
env: 'CL_CLI_ACCESS_TOKEN',
dependsOn: ['organization']
dependsOn: ['organization'],
}),
open: Flags.boolean({
description: 'open checkout URL in default browser'
})
/*,
link: Flags.boolean({
char: 'l',
description: 'generate short link'
})
*/
}


// INIT (override)
async init(): Promise<any> {
clUpdate.checkUpdate(pkg)
return super.init()
}


Expand All @@ -62,31 +31,13 @@ export default abstract class extends Command {


protected async handleError(error: CommandError, flags?: any): Promise<any> {
if (CommerceLayerStatic.isApiError(error)) {
if (error.status === 401) {
const err = error.first()
this.error(clColor.msg.error(`${err.title}: ${err.detail}`),
{ suggestions: ['Execute login to get access to the organization\'s resources'] },
)
} else this.error(clOutput.formatError(error, flags))
} else return super.catch(error)
if (CommerceLayerStatic.isApiError(error)) this.handleApiError(error, { flags })
else return super.catch(error)
}


protected commercelayerInit(flags: any): CommerceLayerClient {

const organization = flags.organization
const domain = flags.domain
const accessToken = flags.accessToken
const userAgent = clUtil.userAgent(this.config)

return commercelayer({
organization,
domain,
accessToken,
userAgent
})

return commercelayer(this.clientOptions(flags))
}


Expand All @@ -109,5 +60,4 @@ export default abstract class extends Command {
}



export { Args, Flags }
Loading
Loading