From be82bcfa630631cb62bc6b586bed3fcdc501f2f8 Mon Sep 17 00:00:00 2001 From: Scott Lowe Date: Fri, 17 Apr 2020 15:19:33 +0200 Subject: [PATCH] convert commands `key`, `keys`, `key add`, `key rm` to oclif. Also: - Display keys with `name` instead of `title`. - Check for empty key before calling API. Change-type: patch Signed-off-by: Scott Lowe --- automation/capitanodoc/capitanodoc.ts | 7 +- doc/cli.markdown | 61 ++++++++---- lib/actions-oclif/env/rename.ts | 3 +- lib/actions-oclif/env/rm.ts | 3 +- lib/actions-oclif/key/add.ts | 87 +++++++++++++++++ lib/actions-oclif/key/index.ts | 79 ++++++++++++++++ lib/actions-oclif/key/rm.ts | 79 ++++++++++++++++ lib/actions-oclif/keys.ts | 56 +++++++++++ lib/actions/index.coffee | 1 - lib/actions/keys.ts | 129 -------------------------- lib/app-capitano.coffee | 6 -- lib/preparser.ts | 4 + lib/utils/common-flags.ts | 5 + lib/utils/validation.ts | 17 +++- tests/commands/help.spec.ts | 8 +- tests/utils/validation.spec.ts | 54 +++++++++++ 16 files changed, 438 insertions(+), 161 deletions(-) create mode 100644 lib/actions-oclif/key/add.ts create mode 100644 lib/actions-oclif/key/index.ts create mode 100644 lib/actions-oclif/key/rm.ts create mode 100644 lib/actions-oclif/keys.ts delete mode 100644 lib/actions/keys.ts create mode 100644 tests/utils/validation.spec.ts diff --git a/automation/capitanodoc/capitanodoc.ts b/automation/capitanodoc/capitanodoc.ts index 0b04b218..43cb8548 100644 --- a/automation/capitanodoc/capitanodoc.ts +++ b/automation/capitanodoc/capitanodoc.ts @@ -68,7 +68,12 @@ const capitanoDoc = { }, { title: 'Keys', - files: ['build/actions/keys.js'], + files: [ + 'build/actions-oclif/keys.js', + 'build/actions-oclif/key/index.js', + 'build/actions-oclif/key/add.js', + 'build/actions-oclif/key/rm.js', + ], }, { title: 'Logs', diff --git a/doc/cli.markdown b/doc/cli.markdown index 130e8de8..1687b970 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -190,8 +190,8 @@ If you come across any problems or would like to get in touch: - [keys](#keys) - [key <id>](#key-id) - - [key rm <id>](#key-rm-id) - [key add <name> [path]](#key-add-name-path) + - [key rm <id>](#key-rm-id) - Logs @@ -1063,41 +1063,33 @@ output version information in JSON format for programmatic use ## keys -Use this command to list all your SSH keys. +List all SSH keys registered in balenaCloud for the logged in user. Examples: $ balena keys +### Options + ## key <id> -Use this command to show information about a single SSH key. +Display a single SSH key registered in balenaCloud for the logged in user. Examples: $ balena key 17 -## key rm <id> +### Arguments -Use this command to remove a SSH key from balena. +#### ID -Notice this command asks for confirmation interactively. -You can avoid this by passing the `--yes` boolean option. - -Examples: - - $ balena key rm 17 - $ balena key rm 17 --yes +balenaCloud ID for the SSH key ### Options -#### --yes, -y - -confirm non interactively - ## key add <name> [path] -Use this command to associate a new SSH key with your account. +Register an SSH in balenaCloud for the logged in user. If `path` is omitted, the command will attempt to read the SSH key from stdin. @@ -1107,6 +1099,41 @@ Examples: $ balena key add Main ~/.ssh/id_rsa.pub $ cat ~/.ssh/id_rsa.pub | balena key add Main +### Arguments + +#### NAME + +the SSH key name + +#### PATH + +the path to the public key file + +### Options + +## key rm <id> + +Remove a single SSH key registered in balenaCloud for the logged in user. + +The --yes option may be used to avoid interactive confirmation. + +Examples: + + $ balena key rm 17 + $ balena key rm 17 --yes + +### Arguments + +#### ID + +balenaCloud ID for the SSH key + +### Options + +#### -y, --yes + +answer "yes" to all questions (non interactive use) + # Logs ## logs <uuidOrDevice> diff --git a/lib/actions-oclif/env/rename.ts b/lib/actions-oclif/env/rename.ts index 2681dc8a..506aaa0b 100644 --- a/lib/actions-oclif/env/rename.ts +++ b/lib/actions-oclif/env/rename.ts @@ -22,6 +22,7 @@ import * as cf from '../../utils/common-flags'; import * as ec from '../../utils/env-common'; import { getBalenaSdk } from '../../utils/lazy'; import { CommandHelp } from '../../utils/oclif-utils'; +import { parseAsInteger } from '../../utils/validation'; type IArg = import('@oclif/parser').args.IArg; @@ -60,7 +61,7 @@ export default class EnvRenameCmd extends Command { name: 'id', required: true, description: "variable's numeric database ID", - parse: input => ec.parseDbId(input), + parse: input => parseAsInteger(input, 'id'), }, { name: 'value', diff --git a/lib/actions-oclif/env/rm.ts b/lib/actions-oclif/env/rm.ts index 2eaa4b0b..4a2bc17e 100644 --- a/lib/actions-oclif/env/rm.ts +++ b/lib/actions-oclif/env/rm.ts @@ -22,6 +22,7 @@ import Command from '../../command'; import * as ec from '../../utils/env-common'; import { getBalenaSdk } from '../../utils/lazy'; import { CommandHelp } from '../../utils/oclif-utils'; +import { parseAsInteger } from '../../utils/validation'; type IArg = import('@oclif/parser').args.IArg; @@ -63,7 +64,7 @@ export default class EnvRmCmd extends Command { name: 'id', required: true, description: "variable's numeric database ID", - parse: input => ec.parseDbId(input), + parse: input => parseAsInteger(input, 'id'), }, ]; diff --git a/lib/actions-oclif/key/add.ts b/lib/actions-oclif/key/add.ts new file mode 100644 index 00000000..22d51b06 --- /dev/null +++ b/lib/actions-oclif/key/add.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2016-2020 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 { flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import { ExpectedError } from '../../errors'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk } from '../../utils/lazy'; + +interface FlagsDef { + help: void; +} + +interface ArgsDef { + name: string; + path: string; +} + +export default class KeyAddCmd extends Command { + public static description = stripIndent` + Add an SSH key to balenaCloud. + + Register an SSH in balenaCloud for the logged in user. + + If \`path\` is omitted, the command will attempt + to read the SSH key from stdin. + `; + + public static examples = [ + '$ balena key add Main ~/.ssh/id_rsa.pub', + '$ cat ~/.ssh/id_rsa.pub | balena key add Main', + ]; + + public static args = [ + { + name: 'name', + description: 'the SSH key name', + required: true, + }, + { + name: `path`, + description: `the path to the public key file`, + }, + ]; + + public static usage = 'key add [path]'; + + public static flags: flags.Input = { + help: cf.help, + }; + + public static authenticated = true; + + public static readStdin = true; + + public async run() { + const { args: params } = this.parse(KeyAddCmd); + + let key: string; + if (params.path != null) { + const { promisify } = await import('util'); + const readFileAsync = promisify((await import('fs')).readFile); + key = await readFileAsync(params.path, 'utf8'); + } else if (this.stdin.length > 0) { + key = this.stdin; + } else { + throw new ExpectedError('No public key file or path provided.'); + } + + await getBalenaSdk().models.key.create(params.name, key); + } +} diff --git a/lib/actions-oclif/key/index.ts b/lib/actions-oclif/key/index.ts new file mode 100644 index 00000000..90bd744f --- /dev/null +++ b/lib/actions-oclif/key/index.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2016-2020 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 { flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk, getVisuals } from '../../utils/lazy'; +import { parseAsInteger } from '../../utils/validation'; + +type IArg = import('@oclif/parser').args.IArg; + +interface FlagsDef { + help: void; +} + +interface ArgsDef { + id: number; +} + +export default class KeyCmd extends Command { + public static description = stripIndent` + Display an SSH key. + + Display a single SSH key registered in balenaCloud for the logged in user. + `; + + public static examples = ['$ balena key 17']; + + public static args: Array> = [ + { + name: 'id', + description: 'balenaCloud ID for the SSH key', + parse: x => parseAsInteger(x, 'id'), + required: true, + }, + ]; + + public static usage = 'key '; + + public static flags: flags.Input = { + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + const { args: params } = this.parse<{}, ArgsDef>(KeyCmd); + + const key = await getBalenaSdk().models.key.get(params.id); + + // Use 'name' instead of 'title' to match dashboard. + const displayKey = { + id: key.id, + name: key.title, + }; + + console.log(getVisuals().table.vertical(displayKey, ['id', 'name'])); + + // Since the public key string is long, it might + // wrap to lines below, causing the table layout to break. + // See https://github.com/balena-io/balena-cli/issues/151 + console.log('\n' + key.public_key); + } +} diff --git a/lib/actions-oclif/key/rm.ts b/lib/actions-oclif/key/rm.ts new file mode 100644 index 00000000..e514b4a5 --- /dev/null +++ b/lib/actions-oclif/key/rm.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2016-2020 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 { flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; +import Command from '../../command'; +import * as cf from '../../utils/common-flags'; +import { getBalenaSdk } from '../../utils/lazy'; +import { parseAsInteger } from '../../utils/validation'; + +type IArg = import('@oclif/parser').args.IArg; + +interface FlagsDef { + yes: boolean; + help: void; +} + +interface ArgsDef { + id: number; +} + +export default class KeyRmCmd extends Command { + public static description = stripIndent` + Remove an SSH key from balenaCloud. + + Remove a single SSH key registered in balenaCloud for the logged in user. + + The --yes option may be used to avoid interactive confirmation. + `; + + public static examples = ['$ balena key rm 17', '$ balena key rm 17 --yes']; + + public static args: Array> = [ + { + name: 'id', + description: 'balenaCloud ID for the SSH key', + parse: x => parseAsInteger(x, 'id'), + required: true, + }, + ]; + + public static usage = 'key rm '; + + public static flags: flags.Input = { + yes: cf.yes, + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + const { args: params, flags: options } = this.parse( + KeyRmCmd, + ); + + const patterns = await import('../../utils/patterns'); + + await patterns.confirm( + options.yes ?? false, + `Are you sure you want to delete key ${params.id}?`, + ); + + await getBalenaSdk().models.key.remove(params.id); + } +} diff --git a/lib/actions-oclif/keys.ts b/lib/actions-oclif/keys.ts new file mode 100644 index 00000000..cf35ade8 --- /dev/null +++ b/lib/actions-oclif/keys.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2016-2020 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 { flags } from '@oclif/command'; +import { stripIndent } from 'common-tags'; +import Command from '../command'; +import * as cf from '../utils/common-flags'; +import { getBalenaSdk, getVisuals } from '../utils/lazy'; + +interface FlagsDef { + help: void; +} + +export default class KeysCmd extends Command { + public static description = stripIndent` + List the SSH keys in balenaCloud. + + List all SSH keys registered in balenaCloud for the logged in user. + `; + public static examples = ['$ balena keys']; + + public static usage = 'keys'; + + public static flags: flags.Input = { + help: cf.help, + }; + + public static authenticated = true; + + public async run() { + this.parse(KeysCmd); + + const keys = await getBalenaSdk().models.key.getAll(); + + // Use 'name' instead of 'title' to match dashboard. + const displayKeys: Array<{ id: number; name: string }> = keys.map(k => { + return { id: k.id, name: k.title }; + }); + + console.log(getVisuals().table.horizontal(displayKeys, ['id', 'name'])); + } +} diff --git a/lib/actions/index.coffee b/lib/actions/index.coffee index e6bd00cd..7691d2d1 100644 --- a/lib/actions/index.coffee +++ b/lib/actions/index.coffee @@ -20,7 +20,6 @@ module.exports = auth: require('./auth') device: require('./device') tags: require('./tags') - keys: require('./keys') logs: require('./logs') local: require('./local') scan: require('./scan') diff --git a/lib/actions/keys.ts b/lib/actions/keys.ts deleted file mode 100644 index 31d58acb..00000000 --- a/lib/actions/keys.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* -Copyright 2016-2020 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 { CommandDefinition } from 'capitano'; -import { ExpectedError } from '../errors'; -import { getBalenaSdk, getVisuals } from '../utils/lazy'; -import * as commandOptions from './command-options'; - -function parseId(id: string): number { - if (/^[\d]+$/.exec(id) == null) { - throw new ExpectedError('The key id must be an integer'); - } - return Number(id); -} - -export const list: CommandDefinition = { - signature: 'keys', - description: 'list all ssh keys', - help: `\ -Use this command to list all your SSH keys. - -Examples: - - $ balena keys\ -`, - permission: 'user', - async action() { - const keys = await getBalenaSdk().models.key.getAll(); - - console.log(getVisuals().table.horizontal(keys, ['id', 'title'])); - }, -}; - -export const info: CommandDefinition<{ id: string }> = { - signature: 'key ', - description: 'list a single ssh key', - help: `\ -Use this command to show information about a single SSH key. - -Examples: - - $ balena key 17\ -`, - permission: 'user', - async action(params) { - const key = await getBalenaSdk().models.key.get(parseId(params.id)); - - console.log(getVisuals().table.vertical(key, ['id', 'title'])); - - // Since the public key string is long, it might - // wrap to lines below, causing the table layout to break. - // See https://github.com/balena-io/balena-cli/issues/151 - console.log('\n' + key.public_key); - }, -}; - -export const remove: CommandDefinition< - { id: string }, - commandOptions.YesOption -> = { - signature: 'key rm ', - description: 'remove a ssh key', - help: `\ -Use this command to remove a SSH key from balena. - -Notice this command asks for confirmation interactively. -You can avoid this by passing the \`--yes\` boolean option. - -Examples: - - $ balena key rm 17 - $ balena key rm 17 --yes\ -`, - options: [commandOptions.yes], - permission: 'user', - async action(params, options) { - const patterns = await import('../utils/patterns'); - - await patterns.confirm( - options.yes ?? false, - 'Are you sure you want to delete the key?', - ); - - await getBalenaSdk().models.key.remove(parseId(params.id)); - }, -}; - -export const add: CommandDefinition<{ name: string; path: string }> = { - signature: 'key add [path]', - description: 'add a SSH key to balena', - help: `\ -Use this command to associate a new SSH key with your account. - -If \`path\` is omitted, the command will attempt -to read the SSH key from stdin. - -Examples: - - $ balena key add Main ~/.ssh/id_rsa.pub - $ cat ~/.ssh/id_rsa.pub | balena key add Main\ -`, - permission: 'user', - async action(params) { - let key: string; - if (params.path != null) { - const { promisify } = await import('util'); - const readFileAsync = promisify((await import('fs')).readFile); - key = await readFileAsync(params.path, 'utf8'); - } else { - const getStdin = await import('get-stdin'); - key = await getStdin(); - } - - await getBalenaSdk().models.key.create(params.name, key); - }, -}; diff --git a/lib/app-capitano.coffee b/lib/app-capitano.coffee index b5eb7409..6da5ada9 100644 --- a/lib/app-capitano.coffee +++ b/lib/app-capitano.coffee @@ -74,12 +74,6 @@ capitano.command(actions.device.move) capitano.command(actions.device.osUpdate) capitano.command(actions.device.info) -# ---------- Keys Module ---------- -capitano.command(actions.keys.list) -capitano.command(actions.keys.add) -capitano.command(actions.keys.info) -capitano.command(actions.keys.remove) - # ---------- Tags Module ---------- capitano.command(actions.tags.list) capitano.command(actions.tags.set) diff --git a/lib/preparser.ts b/lib/preparser.ts index e8a0bf1b..4a8855a5 100644 --- a/lib/preparser.ts +++ b/lib/preparser.ts @@ -134,6 +134,10 @@ export const convertedCommands = [ 'internal:scandevices', 'internal:osinit', 'join', + 'keys', + 'key', + 'key:add', + 'key:rm', 'leave', 'note', 'os:configure', diff --git a/lib/utils/common-flags.ts b/lib/utils/common-flags.ts index ba17a646..7b688d5d 100644 --- a/lib/utils/common-flags.ts +++ b/lib/utils/common-flags.ts @@ -46,3 +46,8 @@ export const verbose: IBooleanFlag = flags.boolean({ char: 'v', description: 'produce verbose output', }); + +export const yes: IBooleanFlag = flags.boolean({ + char: 'y', + description: 'answer "yes" to all questions (non interactive use)', +}); diff --git a/lib/utils/validation.ts b/lib/utils/validation.ts index 2e13065e..2f4dba4e 100644 --- a/lib/utils/validation.ts +++ b/lib/utils/validation.ts @@ -1,5 +1,5 @@ /* -Copyright 2016-2017 Balena +Copyright 2016-2020 Balena Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ limitations under the License. */ import validEmail = require('@resin.io/valid-email'); +import { ExpectedError } from '../errors'; const APPNAME_REGEX = new RegExp(/^[a-zA-Z0-9_-]+$/); // An regex to detect an IP address, from https://www.regular-expressions.info/ip.html @@ -73,3 +74,17 @@ export function validateShortUuid(input: string): boolean { export function validateUuid(input: string): boolean { return validateLongUuid(input) || validateShortUuid(input); } + +export function parseAsInteger(input: string, paramName?: string) { + // Allow only digits, no leading 0 + if (!/^(0|[1-9][0-9]*)$/.test(input)) { + const message = + paramName == null + ? 'The parameter must be an integer.' + : `The parameter '${paramName}' must be an integer.`; + + throw new ExpectedError(message); + } + + return Number(input); +} diff --git a/tests/commands/help.spec.ts b/tests/commands/help.spec.ts index 3c578e78..a37b2889 100644 --- a/tests/commands/help.spec.ts +++ b/tests/commands/help.spec.ts @@ -61,10 +61,10 @@ Additional commands: env rename change the value of a config or env var for an app, device or service env rm remove a config or env var from an application, device or service envs list the environment or config variables of an application, device or service - key list a single ssh key - key add [path] add a SSH key to balena - key rm remove a ssh key - keys list all ssh keys + key display an SSH key + key add [path] add an SSH key to balenaCloud + key rm remove an SSH key from balenaCloud + keys list the SSH keys in balenaCloud local configure (Re)configure a balenaOS drive or image local flash Flash an image to a drive logout logout from balena diff --git a/tests/utils/validation.spec.ts b/tests/utils/validation.spec.ts new file mode 100644 index 00000000..06a9260d --- /dev/null +++ b/tests/utils/validation.spec.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2020 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 { ExpectedError } from '../../build/errors'; +import { parseAsInteger } from '../../build/utils/validation'; + +describe('parseAsInteger() function', function() { + it('should reject non-numeric characters', () => { + expect(() => parseAsInteger('abc')).to.throw(ExpectedError); + expect(() => parseAsInteger('1a')).to.throw(ExpectedError); + expect(() => parseAsInteger('a1')).to.throw(ExpectedError); + expect(() => parseAsInteger('a')).to.throw(ExpectedError); + expect(() => parseAsInteger('1.0')).to.throw(ExpectedError); + }); + + it('should reject leading zeros', () => { + expect(() => parseAsInteger('01')).to.throw(ExpectedError); + expect(() => parseAsInteger('001')).to.throw(ExpectedError); + }); + + it('should throw with specific message when param name passed', () => { + expect(() => parseAsInteger('abc')).to.throw( + 'The parameter must be an integer.', + ); + }); + + it('should throw with general message when no param name passed', () => { + expect(() => parseAsInteger('abc', 'foo')).to.throw( + "The parameter 'foo' must be an integer.", + ); + }); + + it('should parse integers to number type', () => { + expect(parseAsInteger('100')).to.equal(100); + expect(parseAsInteger('100')).to.be.a('number'); + expect(parseAsInteger('0')).to.equal(0); + expect(parseAsInteger('0')).to.be.a('number'); + }); +});