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.
$ 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 = [
'$ 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,53 +211,17 @@ 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,
});
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 application',
message: 'Select an organization',
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,
});
choices: organizations.map((org) => ({
name: `${org.name} (${org.handle})`,
value: org.handle,
})),
});
}

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 {