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.
You can specify the application device type with the `--type` option.
Otherwise, an interactive dropdown will be shown for you to select from.
You can specify the organization the application should belong to using
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:
$ 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
@ -365,6 +374,10 @@ application name
### Options
#### -o, --organization ORGANIZATION
handle of the organization the application should belong to
#### -t, --type TYPE
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 * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import type * as BalenaSDK from 'balena-sdk';
import type { Application } from 'balena-sdk';
interface FlagsDef {
organization?: string;
type?: string; // application device type
help: void;
}
@ -37,16 +38,26 @@ export default class AppCreateCmd extends Command {
Create a new balena application.
You can specify the application device type with the \`--type\` option.
Otherwise, an interactive dropdown will be shown for you to select from.
You can specify the organization the application should belong to using
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.
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.
`;
$ balena devices supported
`;
public static examples = [
'$ 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 = [
@ -60,6 +71,11 @@ export default class AppCreateCmd extends Command {
public static usage = 'app create <name>';
public static flags: flags.Input<FlagsDef> = {
organization: flags.string({
char: 'o',
description:
'handle of the organization the application should belong to',
}),
type: flags.string({
char: 't',
description:
@ -75,30 +91,62 @@ export default class AppCreateCmd extends Command {
AppCreateCmd,
);
const balena = getBalenaSdk();
// Create application
// Ascertain device type
const deviceType =
options.type ||
(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 {
application = await balena.models.application.create({
application = await getBalenaSdk().models.application.create({
name: params.name,
deviceType,
organization: (await balena.auth.whoami())!,
organization,
});
} catch (err) {
// BalenaRequestError: Request error: Unique key constraint violated
if ((err.message || '').toLowerCase().includes('unique')) {
// BalenaRequestError: Request error: "organization" and "app_name" must be unique.
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;
}
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
/Cannot deactivate devices/i, // balena-api
/Devices must be offline in order to be deactivated\.$/i, // balena-api
/^BalenaOrganizationNotFound/, // balena-sdk
/Request error: Unauthorized$/, // balena-sdk
/^Missing \d+ required arg/, // oclif parser: RequiredArgsError
/Missing required flag/, // oclif parser: RequiredFlagError

View File

@ -26,6 +26,7 @@ import { getBalenaSdk, getVisuals, stripIndent, getCliForm } from './lazy';
import validation = require('./validation');
import { delay } from './helpers';
import { isV13 } from './version';
import { Organization } from 'balena-sdk';
export function authenticate(options: {}): Promise<void> {
const balena = getBalenaSdk();
@ -210,54 +211,18 @@ export function selectApplication(
});
}
export function selectOrCreateApplication() {
const balena = getBalenaSdk();
return balena.models.application
.hasAny()
.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({
message: 'Select an application',
type: 'list',
choices: appOptions,
});
});
})
.then((application) => {
if (application) {
return application;
}
return getCliForm().ask({
message: 'Choose a Name for your new application',
type: 'input',
validate: validation.validateApplicationName,
});
});
export async function selectOrganization(organizations?: Organization[]) {
// Use either provided orgs (if e.g. already loaded) or load from cloud
organizations =
organizations || (await getBalenaSdk().models.organization.getAll());
return getCliForm().ask({
message: 'Select an organization',
type: 'list',
choices: organizations.map((org) => ({
name: `${org.name} (${org.handle})`,
value: org.handle,
})),
});
}
export async function awaitDevice(uuid: string) {

View File

@ -32,10 +32,6 @@ export async function getApplication(
nameOrSlugOrId: string | number,
options?: PineOptions<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');
if (looksLikeInteger(nameOrSlugOrId as string)) {
try {