UNPKG

athom-cli

Version:

Command-line interface for Homey Apps

665 lines (553 loc) 17 kB
'use strict'; const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); const util = require('util'); const Log = require('../..').Log; const AthomApi = require('../..').AthomApi; const AppPluginCompose = require('../AppPluginCompose'); const AppPluginZwave = require('../AppPluginZwave'); const AppPluginZigbee = require('../AppPluginZigbee'); const AppPluginRF = require('../AppPluginRF'); const HomeyLibApp = require('homey-lib').App; const HomeyLibDevice = require('homey-lib').Device; const colors = require('colors'); const inquirer = require('inquirer'); const tmp = require('tmp-promise'); const tar = require('tar-fs'); const semver = require('semver'); const npm = require('npm-programmatic'); const gitIgnoreParser = require('gitignore-parser'); const { monitorCtrlC } = require('monitorctrlc'); const fse = require('fs-extra'); const statAsync = util.promisify( fs.stat ); const mkdirAsync = util.promisify( fs.mkdir ); const readFileAsync = util.promisify( fs.readFile ); const writeFileAsync = util.promisify( fs.writeFile ); const copyFileAsync = util.promisify( fs.copyFile ); const PLUGINS = { 'compose': AppPluginCompose, 'zwave': AppPluginZwave, 'zigbee': AppPluginZigbee, 'rf': AppPluginRF, }; class App { constructor( appPath ) { this.path = appPath; this._app = new HomeyLibApp( this.path ); this._pluginsPath = path.join( this.path, '.homeyplugins.json'); this._exiting = false; this._std = {}; } async validate({ level = 'debug' } = {}) { try { let valid = await this._app.validate({ level }); Log(colors.green(`✓ Homey App validated successfully against level \`${level}\``)); return true; } catch( err ) { Log(colors.red(`✘ Homey App did not validate against level \`${level}\`:`)); Log(err.message); return false; } } async build() { Log(colors.green('✓ Building app...')); await this.preprocess(); Log(colors.green('✓ Validating app...')); let valid = await this.validate(); if( valid !== true ) throw new Error('The app is not valid, please fix the validation issues'); Log(colors.green('✓ App built successfully')); } async run({ clean = false, } = {}) { this._session = await this.install({ clean, debug: true, }); clean && Log(colors.green(`✓ Purged all Homey App settings`)); Log(colors.green(`✓ Running \`${this._session.appId}\`, press CTRL+C to quit`)); Log('─────────────── Logging stdout & stderr ───────────────'); let activeHomey = await AthomApi.getActiveHomey(); activeHomey.devkit.subscribe() .then(() => { return activeHomey.devkit.getAppStdOut({ session: this._session.session }) }).then( stdCache => { stdCache .map(std => { std.chunk = new Buffer(std.chunk); return std; }) .forEach(this._onStd.bind(this)); }).catch( Log ) activeHomey.devkit.on('std', this._onStd.bind(this)); monitorCtrlC(this._onCtrlC.bind(this)); } async install({ clean = false, debug = false, } = {}) { await this.preprocess(); Log(colors.green('✓ Validating app...')); let valid = await this.validate(); if( valid !== true ) throw new Error('Not installing, please fix the validation issues first'); let activeHomey = await AthomApi.getActiveHomey(); Log(colors.green(`✓ Packing Homey App...`)); let archiveStream = await this._getPackStream(); let env = await this._getEnv(); env = JSON.stringify(env); let form = { app: archiveStream, debug: debug, env: env, purgeSettings: clean, } Log(colors.green(`✓ Installing Homey App on \`${activeHomey.name}\` (${await activeHomey.baseUrl})...`)); let result = await activeHomey.devkit._call('POST', '/', { form: form, opts: { $timeout: 1000 * 60 * 5 // 5 min }, }); Log(colors.green(`✓ Homey App \`${result.appId}\` successfully installed`)); return result; } async preprocess() { let appJson; try { appJson = path.join( this.path, 'app.json' ); appJson = await readFileAsync( appJson, 'utf8' ); appJson = JSON.parse( appJson ); } catch( err ) { if( err.code === 'ENOENT' ) throw new Error(`Could not find a valid Homey App at \`${this.path}\``); throw new Error(`Error in \`app.json\`:\n${err}`); } let plugins = await this._getPlugins(); if( plugins.length < 1 ) return; Log(colors.green('✓ Running plugins...')); for( let i = 0; i < plugins.length; i++ ) { let plugin = plugins[i]; let pluginId = plugin.id; let pluginClass = PLUGINS[ pluginId ]; if( typeof pluginClass !== 'function' ) throw new Error(`Invalid plugin: ${pluginId}`); Log(colors.green(`✓ Running plugin \`${pluginId}\`...`)); let pluginInstance = new pluginClass( this, plugin.options ); try { await pluginInstance.run(); Log(colors.green(`✓ Plugin \`${pluginId}\` finished`)); } catch( err ) { throw new Error(`✓ Plugin \`${pluginId}\` did not finish:\n${err.message}\n\nAborting.`); } } } async _hasPlugin( pluginId ) { let plugins = await this._getPlugins(); for( let i = 0; i < plugins.length; i++ ) { let plugin = plugins[i]; if( plugin.id === pluginId ) return true; } return false; } async _getPlugins() { try { let plugins = await readFileAsync( this._pluginsPath ); return JSON.parse(plugins); } catch( err ) { if( err.code !== 'ENOENT' ) throw new Error(`Error in \`.homeyplugins.json\`:\n${err}`); } return []; } async addPlugin( pluginId ) { if( await this._hasPlugin(pluginId) ) return; let plugins = await this._getPlugins(); plugins.push({ id: 'zwave' }) await this._savePlugins( plugins ); } async _savePlugins( plugins ) { await writeFileAsync( this._pluginsPath, JSON.stringify(plugins, false, 2) ); } async installNpmPackage({ id, version = 'latest' }) { Log(colors.green(`✓ Installing ${id}@${version}...`)); await npm.install([`${id}@${version}`], { save: true, cwd: this.path, }) Log(colors.green(`✓ Installation complete`)); } _onStd( std ) { if( this._exiting ) return; if( std.session !== this._session.session ) return; if( this._std[ std.id ] ) return; if( std.type === 'stdout' ) process.stdout.write( std.chunk ); if( std.type === 'stderr' ) process.stderr.write( std.chunk ); // mark std as received to prevent duplicates this._std[ std.id ] = true; } async _onCtrlC() { if( this._exiting ) return; this._exiting = true; Log('───────────────────────────────────────────────────────'); Log(colors.green(`✓ Uninstalling \`${this._session.appId}\`...`)); try { let activeHomey = await AthomApi.getActiveHomey(); await activeHomey.devkit.stopApp({ session: this._session.session }); Log(colors.green(`✓ Homey App \`${this._session.appId}\` successfully uninstalled`)); } catch( err ) { Log(err.message || err.toString()); } process.exit(); } async _getEnv() { try { let data = await readFileAsync( path.join(this.path, 'env.json') ); return JSON.parse(data); } catch( err ) { return {}; } } async _getPackStream() { return tmp.file().then( async o => { let tmpPath = o.path; let homeyIgnore; try { let homeyIgnoreContents = await readFileAsync( path.join( this.path, '.homeyignore'), 'utf8' ); homeyIgnore = gitIgnoreParser.compile( homeyIgnoreContents ); } catch( err ){} let tarOpts = { ignore: (name) => { // ignore env.json if( name == path.join( this.path, 'env.json' ) ) return true; // ignore dotfiles (.git, .gitignore, .mysecretporncollection etc.) if( path.basename(name).charAt(0) === '.' ) return true; // ignore .homeyignore files if( homeyIgnore ) { return homeyIgnore.denies( name.replace(this.path, '') ); } return false; }, dereference: true }; return new Promise((resolve, reject) => { let writeFileStream = fs.createWriteStream( tmpPath ) .once('close', () => { let readFileStream = fs.createReadStream( tmpPath ); readFileStream.once('close', () => { o.cleanup(); }) resolve( readFileStream ); }) .once('error', reject) tar .pack( this.path, tarOpts ) .pipe( zlib.createGzip() ) .pipe( writeFileStream ) }); }) } async createDriver() { let zwaveDetails; let answers = await inquirer.prompt([].concat( [ { type: 'input', name: 'name', message: 'What is your Driver\'s Name?', validate: input => { return input.length > 0; } }, { type: 'input', name: 'id', message: 'What is your Driver\'s ID?', default: answers => { let name = answers.name; name = name.toLowerCase(); name = name.replace(/ /g, '-'); name = name.replace(/[^0-9a-zA-Z-_]+/g, ''); return name; }, validate: async input => { if( input.search(/^[a-zA-Z0-9-_]+$/) === -1 ) throw new Error('Invalid characters: only use [a-zA-Z0-9-_]'); if( await fse.exists( path.join(this.path, 'drivers', input) ) ) throw new Error('Driver directory already exists!'); return true; } }, { type: 'list', name: 'class', message: 'What is your Driver\'s Device Class?', choices: () => { let classes = HomeyLibDevice.getClasses(); return Object.keys(classes) .sort(( a, b ) => { a = classes[a]; b = classes[b]; return a.title.en.localeCompare( b.title.en ) }) .map( classId => { return { name: classes[classId].title.en + colors.grey(` (${classId})`), value: classId, } }) } }, { type: 'checkbox', name: 'capabilities', message: 'What are your Driver\'s Capabilities?', choices: () => { let capabilities = HomeyLibDevice.getCapabilities(); return Object.keys(capabilities) .sort(( a, b ) => { a = capabilities[a]; b = capabilities[b]; return a.title.en.localeCompare( b.title.en ) }) .map( capabilityId => { let capability = capabilities[capabilityId]; return { name: capability.title.en + colors.grey(` (${capabilityId})`), value: capabilityId, } }) } }, ], // TODO pair AppPluginZwave.createDriverQuestions(), AppPluginZigbee.createDriverQuestions(), AppPluginRF.createDriverQuestions(), [ { type: 'confirm', name: 'confirm', message: 'Seems good?' } ] )); if( !answers.confirm ) return; let driverId = answers.id; let driverPath = path.join( this.path, 'drivers', driverId ); let driverJson = { id: driverId, name: { en: answers.name, }, class: answers.class, capabilities: answers.capabilities, images: { large: `/drivers/${driverId}/assets/images/large.png`, small: `/drivers/${driverId}/assets/images/small.png`, }, } await fse.ensureDir(driverPath); await fse.ensureDir( path.join(driverPath, 'assets') ); await fse.ensureDir( path.join(driverPath, 'assets', 'images') ); let templatePath = path.join(__dirname, '..', '..', 'assets', 'templates', 'app', 'drivers'); await copyFileAsync( path.join(templatePath, 'driver.js'), path.join(driverPath, 'driver.js') ); await copyFileAsync( path.join(templatePath, 'device.js'), path.join(driverPath, 'device.js') ); if( answers.isZwave ) { await AppPluginZwave.createDriver({ driverId, driverPath, answers, driverJson, app: this, }); } if( answers.isZigbee ) { await AppPluginZigbee.createDriver({ driverId, driverPath, answers, driverJson, app: this, }); } if( answers.isRf ) { await AppPluginRF.createDriver({ driverId, driverPath, answers, driverJson, app: this, }); } let hasCompose = await this._hasPlugin('compose'); if( hasCompose ) { if( driverJson.settings ) { let driverJsonSettings = driverJson.settings; delete driverJson.settings; await writeFileAsync( path.join(driverPath, 'driver.settings.compose.json'), JSON.stringify(driverJsonSettings, false, 2) ); } if( driverJson.flow ) { let driverJsonFlow = driverJson.flow; delete driverJson.flow; await writeFileAsync( path.join(driverPath, 'driver.flow.compose.json'), JSON.stringify(driverJsonFlow, false, 2) ); } await writeFileAsync( path.join(driverPath, 'driver.compose.json'), JSON.stringify(driverJson, false, 2) ); } else { let appJsonPath = path.join(this.path, 'app.json'); let appJson = await readFileAsync( appJsonPath ); appJson = appJson.toString(); appJson = JSON.parse(appJson); appJson.drivers = appJson.drivers || []; appJson.drivers.push( driverJson ); await writeFileAsync( appJsonPath, JSON.stringify(appJson, false, 2) ); } Log(colors.green(`✓ Driver created in \`${driverPath}\``)); } static async create({ appPath }) { let stat = await statAsync( appPath ); if( !stat.isDirectory() ) throw new Error('Invalid path, must be a directory'); let answers = await inquirer.prompt([ { type: 'input', name: 'id', message: 'What is your app\'s unique ID?', default: 'com.athom.myapp', validate: input => { return HomeyLibApp.isValidId( input ); } }, { type: 'input', name: 'name', message: 'What is your app\'s name?', default: 'My App', validate: input => { return input.length > 0; } }, { type: 'input', name: 'description', message: 'What is your app\'s description?', default: 'Adds support for MyBrand devices.', validate: input => { return input.length > 0; } }, { type: 'list', name: 'category', message: 'What is your app\'s category?', choices: HomeyLibApp.getCategories() }, { type: 'input', name: 'version', message: 'What is your app\'s version?', default: '1.0.0', validate: input => { return semver.valid(input) === input; } }, { type: 'input', name: 'compatibility', message: 'What is your app\'s compatibility?', default: '>=1.5.0', validate: input => { return semver.validRange(input) !== null; } }, { type: 'confirm', name: 'license', message: 'Use standard license for Homey Apps (GPL3)?' }, { type: 'confirm', name: 'confirm', message: 'Seems good?' } ]); if( !answers.confirm ) return; const appJson = { id: answers.id, version: answers.version, compatibility: answers.compatibility, sdk: 2, name: { en: answers.name, }, description: { en: answers.description, }, category: [ answers.category ], permissions: [], images: { large: '/assets/images/large.png', small: '/assets/images/small.png' } } try { let profile = await AthomApi.getProfile(); appJson.author = { name: `${profile.firstname} ${profile.lastname}`, email: profile.email } } catch( err ) {} appPath = path.join( appPath, appJson.id ); try { let stat = await statAsync( appPath ); throw new Error(`Path ${appPath} already exists`); } catch( err ) { if( err.code === undefined ) throw err; } // make dirs const dirs = [ '', 'locales', 'drivers', 'assets', path.join('assets', 'images'), ]; for( let i = 0; i < dirs.length; i++ ) { let dir = dirs[i]; try { await mkdirAsync( path.join(appPath, dir) ); } catch( err ) { Log( err ); } } await writeFileAsync( path.join(appPath, 'app.json'), JSON.stringify(appJson, false, 2) ); await writeFileAsync( path.join(appPath, 'locales', 'en.json'), JSON.stringify({}, false, 2) ); await writeFileAsync( path.join(appPath, 'app.js'), '' ); await writeFileAsync( path.join(appPath, 'README.md'), `# ${appJson.name.en}\n\n${appJson.description.en}` ); // copy files const templatePath = path.join(__dirname, '..', '..', 'assets', 'templates', 'app'); const files = [ 'app.js', path.join('assets', 'icon.svg'), ] if( answers.license ) { files.push('LICENSE'); files.push('CODE_OF_CONDUCT.md'); files.push('CONTRIBUTING.md'); } for( let i = 0; i < files.length; i++ ) { let file = files[i]; try { await copyFileAsync( path.join(templatePath, file), path.join( appPath, file ) ); } catch( err ) { Log( err ); } } Log(colors.green(`✓ App created in \`${appPath}\``)); } } module.exports = App;