diff --git a/docs/balena-cli.md b/docs/balena-cli.md index cfe53735..2649d1f9 100644 --- a/docs/balena-cli.md +++ b/docs/balena-cli.md @@ -621,6 +621,10 @@ password TCP port number of local HTTP login server (--web auth only) +#### -H, --hideExperimentalWarning + +Hides warning for experimental features + ## logout Logout from your balena account. diff --git a/lib/commands/login.ts b/lib/commands/login.ts index 24484a40..4e8ce038 100644 --- a/lib/commands/login.ts +++ b/lib/commands/login.ts @@ -20,6 +20,7 @@ import Command from '../command'; import * as cf from '../utils/common-flags'; import { getBalenaSdk, stripIndent, getCliForm } from '../utils/lazy'; import { ExpectedError } from '../errors'; +import type { WhoamiResult } from 'balena-sdk'; interface FlagsDef { token: boolean; @@ -30,6 +31,7 @@ interface FlagsDef { password?: string; port?: number; help: void; + hideExperimentalWarning: boolean; } interface ArgsDef { @@ -114,6 +116,11 @@ export default class LoginCmd extends Command { 'TCP port number of local HTTP login server (--web auth only)', dependsOn: ['web'], }), + hideExperimentalWarning: flags.boolean({ + char: 'H', + default: false, + description: 'Hides warning for experimental features', + }), help: cf.help, }; @@ -137,9 +144,24 @@ export default class LoginCmd extends Command { console.log(`\nLogging in to ${balenaUrl}`); await this.doLogin(options, balenaUrl, params.token); - const { username } = await balena.auth.getUserInfo(); + // We can safely assume this won't be undefined as doLogin will throw if this call fails + // We also don't need to worry too much about the amount of calls to whoami + // as these are cached by the SDK + const whoamiResult = (await balena.auth.whoami()) as WhoamiResult; - console.info(`Successfully logged in as: ${username}`); + if (whoamiResult.actorType !== 'user' && !options.hideExperimentalWarning) { + console.info(stripIndent` + ---------------------------------------------------------------------------------------- + You are logging in with a ${whoamiResult.actorType} key. + This is an experimental feature and many features of the CLI might not work as expected. + We sure hope you know what you are doing. + ---------------------------------------------------------------------------------------- + `); + } + + console.info( + `Successfully logged in as: ${this.getLoggedInMessage(whoamiResult)}`, + ); console.info(`\ Find out about the available commands by running: @@ -149,6 +171,16 @@ Find out about the available commands by running: ${messages.reachingOut}`); } + private getLoggedInMessage(whoami: WhoamiResult): string { + if (whoami.actorType === 'user') { + return whoami.username; + } + + const identifier = + whoami.actorType === 'device' ? whoami.uuid : whoami.slug; + return `${whoami.actorType} ${identifier}`; + } + async doLogin( loginOptions: FlagsDef, balenaUrl: string = 'balena-cloud.com', @@ -166,7 +198,9 @@ ${messages.reachingOut}`); const balena = getBalenaSdk(); await balena.auth.loginWithToken(token!); try { - await balena.auth.getUserInfo(); + if (!(await balena.auth.whoami())) { + throw new ExpectedError('Token authentication failed'); + } } catch (err) { if (process.env.DEBUG) { console.error(`Get user info failed with: ${err.message}`); diff --git a/lib/commands/whoami.ts b/lib/commands/whoami.ts index c4f0600f..4ca4f8f1 100644 --- a/lib/commands/whoami.ts +++ b/lib/commands/whoami.ts @@ -36,18 +36,37 @@ export default class WhoamiCmd extends Command { const balena = getBalenaSdk(); - const [{ username, email }, url] = await Promise.all([ - balena.auth.getUserInfo(), + const [whoamiResult, url] = await Promise.all([ + balena.auth.whoami(), balena.settings.get('balenaUrl'), ]); - console.log( - getVisuals().table.vertical({ username, email, url }, [ - '$account information$', - 'username', - 'email', - 'url', - ]), - ); + if (whoamiResult?.actorType === 'user') { + const { username, email } = whoamiResult; + console.log( + getVisuals().table.vertical({ username, email, url }, [ + '$account information$', + 'username', + 'email', + 'url', + ]), + ); + } else if (whoamiResult?.actorType === 'device') { + console.log( + getVisuals().table.vertical({ device: whoamiResult.uuid, url }, [ + '$account information$', + 'device', + 'url', + ]), + ); + } else if (whoamiResult?.actorType === 'application') { + console.log( + getVisuals().table.vertical({ application: whoamiResult.slug, url }, [ + '$account information$', + 'application', + 'url', + ]), + ); + } } } diff --git a/lib/utils/sdk.ts b/lib/utils/sdk.ts index f8544b7a..0df612df 100644 --- a/lib/utils/sdk.ts +++ b/lib/utils/sdk.ts @@ -43,6 +43,29 @@ export async function getApplication( options?: PineOptions, ): Promise { const { looksLikeFleetSlug } = await import('./validation'); + const whoamiResult = await sdk.auth.whoami(); + const isDeviceActor = whoamiResult?.actorType === 'device'; + + if (isDeviceActor) { + const $filterByActor = { + $filter: { + owns__device: { + $any: { + $alias: 'd', + $expr: { + d: { + actor: whoamiResult.id, + }, + }, + }, + }, + }, + }; + options = options + ? sdk.utils.mergePineOptions(options, $filterByActor) + : $filterByActor; + } + if ( typeof nameOrSlugOrId === 'string' && !looksLikeFleetSlug(nameOrSlugOrId) @@ -52,13 +75,15 @@ export async function getApplication( return await sdk.models.application.getAppByName( nameOrSlugOrId, options, - 'directly_accessible', + isDeviceActor ? undefined : 'directly_accessible', ); } - return await sdk.models.application.getDirectlyAccessible( - nameOrSlugOrId, - options, - ); + + const getFunction = isDeviceActor + ? sdk.models.application.get + : sdk.models.application.getDirectlyAccessible; + + return getFunction(nameOrSlugOrId, options); } /** diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 2e9be5c5..a7688f79 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -3082,9 +3082,9 @@ } }, "node_modules/@types/node": { - "version": "16.18.42", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.42.tgz", - "integrity": "sha512-IrFfX/1oxDFQNpQzgt/BoP/hbMuQT68DPsNwzJmw8y3K8lfnPp0XymVN9GLFz+LobFmJGZ/peRzq+9wXYfCCtw==" + "version": "16.18.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.43.tgz", + "integrity": "sha512-YFpgPKPRcwYbeNOimfu70B+TVJe6tr88WiW/TzEldkwGxQXrmabpU+lDjrFlNqdqIi3ON0o69EQBW62VH4MIxw==" }, "node_modules/@types/node-cleanup": { "version": "2.1.2", @@ -24782,9 +24782,9 @@ } }, "@types/node": { - "version": "16.18.42", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.42.tgz", - "integrity": "sha512-IrFfX/1oxDFQNpQzgt/BoP/hbMuQT68DPsNwzJmw8y3K8lfnPp0XymVN9GLFz+LobFmJGZ/peRzq+9wXYfCCtw==" + "version": "16.18.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.43.tgz", + "integrity": "sha512-YFpgPKPRcwYbeNOimfu70B+TVJe6tr88WiW/TzEldkwGxQXrmabpU+lDjrFlNqdqIi3ON0o69EQBW62VH4MIxw==" }, "@types/node-cleanup": { "version": "2.1.2", diff --git a/tests/commands/whoami.spec.ts b/tests/commands/whoami.spec.ts new file mode 100644 index 00000000..14140d19 --- /dev/null +++ b/tests/commands/whoami.spec.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2019-2023 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 { expect } from 'chai'; + +import { BalenaAPIMock } from '../nock/balena-api-mock'; +import { cleanOutput, runCommand } from '../helpers'; + +describe('balena whoami', function () { + let api: BalenaAPIMock; + + this.beforeEach(() => { + api = new BalenaAPIMock(); + api.expectGetMixpanel({ optional: true }); + }); + + this.afterEach(async () => { + // Check all expected api calls have been made and clean up. + api.done(); + }); + + it(`should output login required message if haven't logged in`, async () => { + api.expectWhoAmIFail(); + const { err, out } = await runCommand('whoami'); + expect(out).to.be.empty; + expect(err[0]).to.include('Login required'); + }); + + it('should display device with device response', async () => { + api.expectDeviceWhoAmI(); + const { err, out } = await runCommand('whoami'); + + const lines = cleanOutput(out); + expect(lines[0]).to.contain('== ACCOUNT INFORMATION'); + expect(lines[1]).to.contain('DEVICE: a11dc1acd31b623a0e4e084a6cf13aaa'); + expect(lines[2]).to.contain('URL: balena-cloud.com'); + expect(err).to.be.empty; + }); + + it('should display application with application response', async () => { + api.expectApplicationWhoAmI(); + const { err, out } = await runCommand('whoami'); + + const lines = cleanOutput(out); + expect(lines[0]).to.contain('== ACCOUNT INFORMATION'); + expect(lines[1]).to.contain('APPLICATION: mytestorf/mytestfleet'); + expect(lines[2]).to.contain('URL: balena-cloud.com'); + expect(err).to.be.empty; + }); + + it('should display user with user response', async () => { + api.expectGetWhoAmI(); + const { err, out } = await runCommand('whoami'); + + const lines = cleanOutput(out); + expect(lines[0]).to.contain('== ACCOUNT INFORMATION'); + expect(lines[1]).to.contain('USERNAME: gh_user'); + expect(lines[2]).to.contain('EMAIL: testuser@test.com'); + expect(lines[3]).to.contain('URL: balena-cloud.com'); + expect(err).to.be.empty; + }); +}); diff --git a/tests/nock/balena-api-mock.ts b/tests/nock/balena-api-mock.ts index 3fd53f58..fd8da19c 100644 --- a/tests/nock/balena-api-mock.ts +++ b/tests/nock/balena-api-mock.ts @@ -439,6 +439,28 @@ export class BalenaAPIMock extends NockMock { }); } + public expectDeviceWhoAmI(opts: ScopeOpts = { optional: true }) { + this.optGet('/actor/v1/whoami', opts).reply(200, { + id: 1235, + actorType: 'device', + actorTypeId: 88888, + uuid: 'a11dc1acd31b623a0e4e084a6cf13aaa', + }); + } + + public expectApplicationWhoAmI(opts: ScopeOpts = { optional: true }) { + this.optGet('/actor/v1/whoami', opts).reply(200, { + id: 1236, + actorType: 'application', + actorTypeId: 77777, + slug: 'mytestorf/mytestfleet', + }); + } + + public expectWhoAmIFail(opts: ScopeOpts = { optional: true }) { + this.optGet('/actor/v1/whoami', opts).reply(401); + } + public expectGetMixpanel(opts: ScopeOpts = {}) { this.optGet(/^\/mixpanel\/track/, opts).reply(200, {}); }