Add organizations support to app create command

Change-type: minor
Connects-to: #2119
Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
Scott Lowe
2020-12-09 16:05:12 +01:00
parent c6430274e5
commit 611f59a0da
5 changed files with 97 additions and 74 deletions

View File

@ -345,17 +345,26 @@ application name or org/name slug
Create a new balena application. Create a new balena application.
You can specify the application device type with the `--type` option. You can specify the organization the application should belong to using
Otherwise, an interactive dropdown will be shown for you to select from. the `--organization` option. The organization's handle, not its name,
should be provided. Organization handles can be listed with the
`balena orgs` command.
You can see a list of supported device types with: The application's default device type is specified with the `--type` option.
The `balena devices supported` command can be used to list the available
device types.
$ balena devices supported Interactive dropdowns will be shown for selection if no device type or
organization is specified and there are multiple options to choose from.
If there is a single option to choose from, it will be chosen automatically.
This interactive behavior can be disabled by explicitly specifying a device
type and organization.
Examples: Examples:
$ balena app create MyApp $ balena app create MyApp
$ balena app create MyApp --type raspberry-pi $ balena app create MyApp --organization mmyorg
$ balena app create MyApp -o myorg --type raspberry-pi
### Arguments ### Arguments
@ -365,6 +374,10 @@ application name
### Options ### Options
#### -o, --organization ORGANIZATION
handle of the organization the application should belong to
#### -t, --type TYPE #### -t, --type TYPE
application device type (Check available types with `balena devices supported`) application device type (Check available types with `balena devices supported`)

View File

@ -20,9 +20,10 @@ import Command from '../../command';
import { ExpectedError } from '../../errors'; import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags'; import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy'; import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import type * as BalenaSDK from 'balena-sdk'; import type { Application } from 'balena-sdk';
interface FlagsDef { interface FlagsDef {
organization?: string;
type?: string; // application device type type?: string; // application device type
help: void; help: void;
} }
@ -37,16 +38,26 @@ export default class AppCreateCmd extends Command {
Create a new balena application. Create a new balena application.
You can specify the application device type with the \`--type\` option. You can specify the organization the application should belong to using
Otherwise, an interactive dropdown will be shown for you to select from. the \`--organization\` option. The organization's handle, not its name,
should be provided. Organization handles can be listed with the
\`balena orgs\` command.
You can see a list of supported device types with: The application's default device type is specified with the \`--type\` option.
The \`balena devices supported\` command can be used to list the available
device types.
$ balena devices supported Interactive dropdowns will be shown for selection if no device type or
organization is specified and there are multiple options to choose from.
If there is a single option to choose from, it will be chosen automatically.
This interactive behavior can be disabled by explicitly specifying a device
type and organization.
`; `;
public static examples = [ public static examples = [
'$ balena app create MyApp', '$ balena app create MyApp',
'$ balena app create MyApp --type raspberry-pi', '$ balena app create MyApp --organization mmyorg',
'$ balena app create MyApp -o myorg --type raspberry-pi',
]; ];
public static args = [ public static args = [
@ -60,6 +71,11 @@ export default class AppCreateCmd extends Command {
public static usage = 'app create <name>'; public static usage = 'app create <name>';
public static flags: flags.Input<FlagsDef> = { public static flags: flags.Input<FlagsDef> = {
organization: flags.string({
char: 'o',
description:
'handle of the organization the application should belong to',
}),
type: flags.string({ type: flags.string({
char: 't', char: 't',
description: description:
@ -75,30 +91,62 @@ export default class AppCreateCmd extends Command {
AppCreateCmd, AppCreateCmd,
); );
const balena = getBalenaSdk(); // Ascertain device type
// Create application
const deviceType = const deviceType =
options.type || options.type ||
(await (await import('../../utils/patterns')).selectDeviceType()); (await (await import('../../utils/patterns')).selectDeviceType());
let application: BalenaSDK.Application;
// Ascertain organization
const organization =
options.organization?.toLowerCase() || (await this.getOrganization());
// Create application
let application: Application;
try { try {
application = await balena.models.application.create({ application = await getBalenaSdk().models.application.create({
name: params.name, name: params.name,
deviceType, deviceType,
organization: (await balena.auth.whoami())!, organization,
}); });
} catch (err) { } catch (err) {
// BalenaRequestError: Request error: Unique key constraint violated
if ((err.message || '').toLowerCase().includes('unique')) { if ((err.message || '').toLowerCase().includes('unique')) {
// BalenaRequestError: Request error: "organization" and "app_name" must be unique.
throw new ExpectedError( throw new ExpectedError(
`Error: application "${params.name}" already exists`, `Error: application "${params.name}" already exists in organization "${organization}".`,
);
} else if ((err.message || '').toLowerCase().includes('unauthorized')) {
// BalenaRequestError: Request error: Unauthorized
throw new ExpectedError(
`Error: You are not authorized to create applications in organization "${organization}".`,
); );
} }
throw err; throw err;
} }
console.info(
`Application created: ${application.slug} (${deviceType}, id ${application.id})`, // Output
const { isV13 } = await import('../../utils/version');
console.log(
isV13()
? `Application created: slug "${application.slug}", device type "${deviceType}"`
: `Application created: ${application.slug} (${deviceType}, id ${application.id})`,
); );
} }
async getOrganization() {
const { getOwnOrganizations } = await import('../../utils/sdk');
const organizations = await getOwnOrganizations(getBalenaSdk());
if (organizations.length === 0) {
// User is not a member of any organizations (should not happen).
throw new Error('This account is not a member of any organizations');
} else if (organizations.length === 1) {
// User is a member of only one organization - use this.
return organizations[0].handle;
} else {
// User is a member of multiple organizations -
const { selectOrganization } = await import('../../utils/patterns');
return selectOrganization(organizations);
}
}
} }

View File

@ -189,6 +189,7 @@ const EXPECTED_ERROR_REGEXES = [
/^BalenaInvalidDeviceType/, // balena-sdk /^BalenaInvalidDeviceType/, // balena-sdk
/Cannot deactivate devices/i, // balena-api /Cannot deactivate devices/i, // balena-api
/Devices must be offline in order to be deactivated\.$/i, // balena-api /Devices must be offline in order to be deactivated\.$/i, // balena-api
/^BalenaOrganizationNotFound/, // balena-sdk
/Request error: Unauthorized$/, // balena-sdk /Request error: Unauthorized$/, // balena-sdk
/^Missing \d+ required arg/, // oclif parser: RequiredArgsError /^Missing \d+ required arg/, // oclif parser: RequiredArgsError
/Missing required flag/, // oclif parser: RequiredFlagError /Missing required flag/, // oclif parser: RequiredFlagError

View File

@ -26,6 +26,7 @@ import { getBalenaSdk, getVisuals, stripIndent, getCliForm } from './lazy';
import validation = require('./validation'); import validation = require('./validation');
import { delay } from './helpers'; import { delay } from './helpers';
import { isV13 } from './version'; import { isV13 } from './version';
import { Organization } from 'balena-sdk';
export function authenticate(options: {}): Promise<void> { export function authenticate(options: {}): Promise<void> {
const balena = getBalenaSdk(); const balena = getBalenaSdk();
@ -210,53 +211,17 @@ export function selectApplication(
}); });
} }
export function selectOrCreateApplication() { export async function selectOrganization(organizations?: Organization[]) {
const balena = getBalenaSdk(); // Use either provided orgs (if e.g. already loaded) or load from cloud
return balena.models.application organizations =
.hasAny() organizations || (await getBalenaSdk().models.organization.getAll());
.then((hasAnyApplications) => {
if (!hasAnyApplications) {
// Just to make TS happy
return Promise.resolve(undefined);
}
return (balena.models.application.getAll({
$expand: {
is_for__device_type: {
$select: 'slug',
},
},
}) as Promise<ApplicationWithDeviceType[]>).then((applications) => {
const appOptions: Array<{ name: string; value: string | null }> = _.map(
applications,
(application) => ({
name: `${application.app_name} (${application.is_for__device_type[0].slug})`,
value: application.app_name,
}),
);
appOptions.unshift({
name: 'Create a new application',
value: null,
});
return getCliForm().ask({ return getCliForm().ask({
message: 'Select an application', message: 'Select an organization',
type: 'list', type: 'list',
choices: appOptions, choices: organizations.map((org) => ({
}); name: `${org.name} (${org.handle})`,
}); value: org.handle,
}) })),
.then((application) => {
if (application) {
return application;
}
return getCliForm().ask({
message: 'Choose a Name for your new application',
type: 'input',
validate: validation.validateApplicationName,
});
}); });
} }

View File

@ -32,10 +32,6 @@ export async function getApplication(
nameOrSlugOrId: string | number, nameOrSlugOrId: string | number,
options?: PineOptions<Application>, options?: PineOptions<Application>,
): Promise<Application> { ): Promise<Application> {
// TODO: Consider whether it would be useful to generally include interactive selection of application here,
// when nameOrSlugOrId not provided.
// e.g. nameOrSlugOrId || (await (await import('../../utils/patterns')).selectApplication()),
// See commands/device/init.ts ~ln100 for example
const { looksLikeInteger } = await import('./validation'); const { looksLikeInteger } = await import('./validation');
if (looksLikeInteger(nameOrSlugOrId as string)) { if (looksLikeInteger(nameOrSlugOrId as string)) {
try { try {