diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index 1f3327bb..37d53143 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -45,7 +45,11 @@ const capitanoDoc = { }, { title: 'Authentication', - files: ['build/actions/auth.js'], + files: [ + 'build/actions-oclif/login.js', + 'build/actions-oclif/logout.js', + 'build/actions-oclif/whoami.js', + ], }, { title: 'Device', diff --git a/doc/cli.markdown b/doc/cli.markdown index 0d46b3d7..68f1a9e0 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -395,11 +395,11 @@ application name or numeric ID ## login -Use this command to login to your balena account. +Login to your balena account. This command will prompt you to login using the following login types: -- Web authorization: open your web browser and prompt you to authorize the CLI +- Web authorization: open your web browser and prompt to authorize the CLI from the dashboard. - Credentials: using email/password and 2FA. @@ -414,31 +414,41 @@ Examples: $ balena login --credentials $ balena login --credentials --email johndoe@gmail.com --password secret +### Arguments + +#### TOKEN + + + ### Options -#### --token, -t <token> - -session token or API key - -#### --web, -w +#### -w, --web web-based login -#### --credentials, -c +#### -t, --token + +session token or API key + +#### -c, --credentials credential-based login -#### --email, -e, -u <email> +#### -e, --email EMAIL email -#### --password, -p <password> +#### -u, --user USER + + + +#### -p, --password PASSWORD password ## logout -Use this command to logout from your balena account. +Logout from your balena account. Examples: @@ -446,7 +456,7 @@ Examples: ## whoami -Use this command to find out the current logged in username and email address. +Get the username and email address of the currently logged in user. Examples: diff --git a/lib/actions-oclif/device/identify.ts b/lib/actions-oclif/device/identify.ts index ccf9d215..21ff684c 100644 --- a/lib/actions-oclif/device/identify.ts +++ b/lib/actions-oclif/device/identify.ts @@ -65,7 +65,8 @@ export default class DeviceIdentifyCmd extends Command { try { await balena.models.device.identify(params.uuid); } catch (e) { - if (e.message === 'Request error: No online device(s) found') { + // Expected message: 'Request error: No online device(s) found' + if (e.message?.toLowerCase().includes('online')) { throw new ExpectedError(`Device ${params.uuid} is not online`); } else { throw e; diff --git a/lib/actions-oclif/device/shutdown.ts b/lib/actions-oclif/device/shutdown.ts index 74438ff0..0f5a6769 100644 --- a/lib/actions-oclif/device/shutdown.ts +++ b/lib/actions-oclif/device/shutdown.ts @@ -69,7 +69,8 @@ export default class DeviceShutdownCmd extends Command { try { await balena.models.device.shutdown(params.uuid, options); } catch (e) { - if (e.message === 'Request error: No online device(s) found') { + // Expected message: 'Request error: No online device(s) found' + if (e.message?.toLowerCase().includes('online')) { throw new ExpectedError(`Device ${params.uuid} is not online`); } else { throw e; diff --git a/lib/actions-oclif/login.ts b/lib/actions-oclif/login.ts new file mode 100644 index 00000000..77082d99 --- /dev/null +++ b/lib/actions-oclif/login.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2016-2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; +import Command from '../command'; +import * as cf from '../utils/common-flags'; +import { getBalenaSdk } from '../utils/lazy'; +import { ExpectedError } from '../errors'; + +interface FlagsDef { + token: boolean; + web: boolean; + credentials: boolean; + email?: string; + user?: string; + password?: string; + help: void; +} + +interface ArgsDef { + token?: string; +} + +export default class LoginCmd extends Command { + public static description = stripIndent` + Login to balena. + + Login to your balena account. + + This command will prompt you to login using the following login types: + + - Web authorization: open your web browser and prompt to authorize the CLI + from the dashboard. + + - Credentials: using email/password and 2FA. + + - Token: using a session token or API key from the preferences page. +`; + public static examples = [ + '$ balena login', + '$ balena login --web', + '$ balena login --token "..."', + '$ balena login --credentials', + '$ balena login --credentials --email johndoe@gmail.com --password secret', + ]; + + public static args = [ + { + // Capitano allowed -t to be type boolean|string, which oclif does not. + // So -t is now bool, and we check first arg for token content. + name: 'token', + hidden: true, + }, + ]; + + public static usage = 'login'; + + public static flags: flags.Input = { + web: flags.boolean({ + char: 'w', + description: 'web-based login', + }), + token: flags.boolean({ + char: 't', + description: 'session token or API key', + }), + credentials: flags.boolean({ + char: 'c', + description: 'credential-based login', + }), + email: flags.string({ + char: 'e', + description: 'email', + exclusive: ['user'], + dependsOn: ['credentials'], + }), + // Capitano version of this command had a second alias for email, 'u'. + // Using an oclif hidden flag to support the same behaviour. + user: flags.string({ + char: 'u', + hidden: true, + exclusive: ['email'], + dependsOn: ['credentials'], + }), + password: flags.string({ + char: 'p', + description: 'password', + dependsOn: ['credentials'], + }), + help: cf.help, + }; + + public static primary = true; + + public async run() { + const { flags: options, args: params } = this.parse( + LoginCmd, + ); + + const balena = getBalenaSdk(); + const messages = await import('../utils/messages'); + const balenaUrl = await balena.settings.get('balenaUrl'); + + // Consolidate user/email options + if (options.user != null) { + options.email = options.user; + } + + console.log(messages.balenaAsciiArt); + console.log(`\nLogging in to ${balenaUrl}`); + await this.doLogin(options, params.token); + const username = await balena.auth.whoami(); + + console.info(`Successfully logged in as: ${username}`); + console.info(`\ + +Find out about the available commands by running: + + $ balena help + +${messages.reachingOut}`); + + if (options.web) { + const { shutdownServer } = await import('../auth'); + shutdownServer(); + } + } + + async doLogin(loginOptions: FlagsDef, token?: string): Promise { + const patterns = await import('../utils/patterns'); + const balena = getBalenaSdk(); + + // Token + if (loginOptions.token) { + if (!token) { + const form = await import('resin-cli-form'); + token = await form.ask({ + message: 'Session token or API key from the preferences page', + name: 'token', + type: 'input', + }); + } + await balena.auth.loginWithToken(token!); + if (!(await balena.auth.whoami())) { + throw new ExpectedError('Token authentication failed'); + } + return; + } + // Credentials + else if (loginOptions.credentials) { + return patterns.authenticate(loginOptions); + } + // Web + else if (loginOptions.web) { + const auth = await import('../auth'); + await auth.login(); + return; + } + + // User had not selected login preference, prompt interactively + const loginType = await patterns.askLoginType(); + if (loginType === 'register') { + const signupUrl = 'https://dashboard.balena-cloud.com/signup'; + const open = await import('open'); + open(signupUrl, { wait: false }); + throw new ExpectedError(`Please sign up at ${signupUrl}`); + } + + // Set login options flag from askLoginType, and run again + loginOptions[loginType] = true; + return this.doLogin(loginOptions); + } +} diff --git a/lib/actions-oclif/logout.ts b/lib/actions-oclif/logout.ts new file mode 100644 index 00000000..32b2ed27 --- /dev/null +++ b/lib/actions-oclif/logout.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2016-2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { stripIndent } from 'common-tags'; +import Command from '../command'; +import { getBalenaSdk } from '../utils/lazy'; + +export default class LogoutCmd extends Command { + public static description = stripIndent` + Logout from balena. + + Logout from your balena account. +`; + public static examples = ['$ balena logout']; + + public static usage = 'logout'; + + public async run() { + this.parse<{}, {}>(LogoutCmd); + await getBalenaSdk().auth.logout(); + } +} diff --git a/lib/actions-oclif/whoami.ts b/lib/actions-oclif/whoami.ts new file mode 100644 index 00000000..08099d90 --- /dev/null +++ b/lib/actions-oclif/whoami.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2016-2020 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { stripIndent } from 'common-tags'; +import Command from '../command'; +import { getBalenaSdk, getVisuals } from '../utils/lazy'; + +export default class WhoamiCmd extends Command { + public static description = stripIndent` + Get current username and email address. + + Get the username and email address of the currently logged in user. + `; + + public static examples = ['$ balena whoami']; + + public static usage = 'whoami'; + + public static authenticated = true; + + public async run() { + this.parse<{}, {}>(WhoamiCmd); + + const balena = getBalenaSdk(); + + const [username, email, url] = await Promise.all([ + balena.auth.whoami(), + balena.auth.getEmail(), + balena.settings.get('balenaUrl'), + ]); + console.log( + getVisuals().table.vertical({ username, email, url }, [ + '$account information$', + 'username', + 'email', + 'url', + ]), + ); + } +} diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts deleted file mode 100644 index e6b48d6c..00000000 --- a/lib/actions/auth.ts +++ /dev/null @@ -1,196 +0,0 @@ -/* -Copyright 2016-2020 Balena - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { CommandDefinition } from 'capitano'; -import { getBalenaSdk, getVisuals } from '../utils/lazy'; - -export const login: CommandDefinition< - {}, - { - token: string | boolean; - web: boolean; - credentials: boolean; - email: string; - password: string; - } -> = { - signature: 'login', - description: 'login to balena', - help: `\ -Use this command to login to your balena account. - -This command will prompt you to login using the following login types: - -- Web authorization: open your web browser and prompt you to authorize the CLI -from the dashboard. - -- Credentials: using email/password and 2FA. - -- Token: using a session token or API key from the preferences page. - -Examples: - - $ balena login - $ balena login --web - $ balena login --token "..." - $ balena login --credentials - $ balena login --credentials --email johndoe@gmail.com --password secret\ -`, - options: [ - { - signature: 'token', - description: 'session token or API key', - parameter: 'token', - alias: 't', - }, - { - signature: 'web', - description: 'web-based login', - boolean: true, - alias: 'w', - }, - { - signature: 'credentials', - description: 'credential-based login', - boolean: true, - alias: 'c', - }, - { - signature: 'email', - parameter: 'email', - description: 'email', - alias: ['e', 'u'], - }, - { - signature: 'password', - parameter: 'password', - description: 'password', - alias: 'p', - }, - ], - primary: true, - async action(_params, options) { - type Options = typeof options; - const balena = getBalenaSdk(); - const patterns = await import('../utils/patterns'); - const messages = await import('../utils/messages'); - const { exitWithExpectedError } = await import('../errors'); - - const doLogin = async (loginOptions: Options): Promise => { - if (loginOptions.token != null) { - let token: string; - if (typeof loginOptions.token === 'string') { - token = loginOptions.token; - } else { - const form = await import('resin-cli-form'); - token = await form.ask({ - message: 'Session token or API key from the preferences page', - name: 'token', - type: 'input', - }); - } - await balena.auth.loginWithToken(token); - if (!(await balena.auth.whoami())) { - exitWithExpectedError('Token authentication failed'); - } - return; - } else if (loginOptions.credentials) { - return patterns.authenticate(loginOptions); - } else if (loginOptions.web) { - const auth = await import('../auth'); - await auth.login(); - return; - } - - const loginType = await patterns.askLoginType(); - if (loginType === 'register') { - const signupUrl = 'https://dashboard.balena-cloud.com/signup'; - const open = await import('open'); - open(signupUrl, { wait: false }); - return exitWithExpectedError(`Please sign up at ${signupUrl}`); - } - - loginOptions[loginType] = true; - return doLogin(loginOptions); - }; - - const balenaUrl = await balena.settings.get('balenaUrl'); - - console.log(messages.balenaAsciiArt); - console.log(`\nLogging in to ${balenaUrl}`); - await doLogin(options); - const username = await balena.auth.whoami(); - - console.info(`Successfully logged in as: ${username}`); - console.info(`\ - -Find out about the available commands by running: - - $ balena help - -${messages.reachingOut}`); - - if (options.web) { - const { shutdownServer } = await import('../auth'); - shutdownServer(); - } - }, -}; - -export const logout: CommandDefinition = { - signature: 'logout', - description: 'logout from balena', - help: `\ -Use this command to logout from your balena account. - -Examples: - - $ balena logout\ -`, - async action(_params) { - await getBalenaSdk().auth.logout(); - }, -}; - -export const whoami: CommandDefinition = { - signature: 'whoami', - description: 'get current username and email address', - help: `\ -Use this command to find out the current logged in username and email address. - -Examples: - - $ balena whoami\ -`, - permission: 'user', - async action() { - const balena = getBalenaSdk(); - - const [username, email, url] = await Promise.all([ - balena.auth.whoami(), - balena.auth.getEmail(), - balena.settings.get('balenaUrl'), - ]); - console.log( - getVisuals().table.vertical({ username, email, url }, [ - '$account information$', - 'username', - 'email', - 'url', - ]), - ); - }, -}; diff --git a/lib/actions/index.ts b/lib/actions/index.ts index 6f2efd20..556bf185 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as auth from './auth'; import * as config from './config'; import * as device from './device'; import * as help from './help'; @@ -26,7 +25,7 @@ import * as ssh from './ssh'; import * as tunnel from './tunnel'; import * as util from './util'; -export { auth, device, logs, local, help, os, config, ssh, util, push, tunnel }; +export { device, logs, local, help, os, config, ssh, util, push, tunnel }; export { build } from './build'; diff --git a/lib/app-capitano.js b/lib/app-capitano.js index 62d8d95c..da72f08a 100644 --- a/lib/app-capitano.js +++ b/lib/app-capitano.js @@ -47,11 +47,6 @@ capitano.globalOption({ // ---------- Help Module ---------- capitano.command(actions.help.help); -// ---------- Auth Module ---------- -capitano.command(actions.auth.login); -capitano.command(actions.auth.logout); -capitano.command(actions.auth.whoami); - // ---------- Device Module ---------- capitano.command(actions.device.init); diff --git a/lib/errors.ts b/lib/errors.ts index 2fa56160..d4b45b10 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -137,9 +137,11 @@ const EXPECTED_ERROR_REGEXES = [ /^BalenaDeviceNotFound/, // balena-sdk /^BalenaExpiredToken/, // balena-sdk /^Missing \w+$/, // Capitano, - /^Missing \d+ required arg/, // oclif parser: RequiredArgsError, RequiredFlagError + /^Missing \d+ required arg/, // oclif parser: RequiredArgsError + /Missing required flag/, // oclif parser: RequiredFlagError /^Unexpected argument/, // oclif parser: UnexpectedArgsError /to be one of/, // oclif parser: FlagInvalidOptionError, ArgInvalidOptionError + /must also be provided when using/, // oclif parser (depends-on) ]; // Support unit testing of handleError diff --git a/lib/preparser.ts b/lib/preparser.ts index 5130dd76..7ae6e823 100644 --- a/lib/preparser.ts +++ b/lib/preparser.ts @@ -165,14 +165,17 @@ export const convertedCommands = [ 'key:add', 'key:rm', 'leave', + 'login', + 'logout', 'note', 'os:configure', + 'scan', 'settings', 'tags', 'tag:rm', 'tag:set', 'version', - 'scan', + 'whoami', ]; /** diff --git a/tests/errors.spec.ts b/tests/errors.spec.ts index 5486d106..4212b743 100644 --- a/tests/errors.spec.ts +++ b/tests/errors.spec.ts @@ -120,9 +120,11 @@ describe('handleError() function', () => { 'Missing uuid', // Capitano 'Missing 1 required argument', // oclif 'Missing 2 required arguments', // oclif - 'Unexpected argument', - 'Unexpected arguments', - 'to be one of', + 'Missing required flag', // oclif + 'Unexpected argument', // oclif + 'Unexpected arguments', // oclif + 'to be one of', // oclif + 'must also be provided when using', // oclif ]; messagesToMatch.forEach((message) => {