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.
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 = [ 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 {