UNPKG

homey

Version:

Command-line interface and type declarations for Homey Apps

1,604 lines (1,396 loc) 118 kB
'use strict'; const os = require('os'); const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); const http = require('http'); const stream = require('stream'); const { promisify } = require('util'); const sharp = require('sharp'); const { AthomAppsAPI, HomeyAPIV2 } = require('homey-api'); const { getAppLocales } = require('homey-lib'); const HomeyLibApp = require('homey-lib').App; const HomeyLibDevice = require('homey-lib').Device; const HomeyLibUtil = require('homey-lib').Util; const colors = require('colors'); const inquirer = require('inquirer'); const tmp = require('tmp-promise'); const tar = require('tar-fs'); const semver = require('semver'); const ignoreWalk = require('ignore-walk'); const fse = require('fs-extra'); const filesize = require('filesize'); const querystring = require('querystring'); const getPort = require('get-port'); const SocketIOServer = require('socket.io'); const SocketIOClient = require('socket.io-client'); const express = require('express'); const childProcess = require('child_process'); const OpenAI = require('openai'); const PQueue = require('p-queue').default; const AthomApi = require('../services/AthomApi'); const Settings = require('../services/Settings'); const Util = require('./Util'); const Log = require('./Log'); const HomeyCompose = require('./HomeyCompose'); const GitCommands = require('./GitCommands'); const NpmCommands = require('./NpmCommands'); const ZWave = require('./ZWave'); const DockerHelper = require('./DockerHelper'); const exec = promisify(childProcess.exec); const statAsync = promisify(fs.stat); const mkdirAsync = promisify(fs.mkdir); const readFileAsync = promisify(fs.readFile); const writeFileAsync = promisify(fs.writeFile); const copyFileAsync = promisify(fs.copyFile); const readDirAsync = promisify(fs.readdir); const pipeline = promisify(stream.pipeline); const INVALID_CHARACTERS = /[^a-zA-Z0-9-_]/g; const FLOW_TYPES = ['triggers', 'conditions', 'actions']; class App { constructor(appPath) { this.path = path.resolve(appPath); this._homeyBuildPath = path.join(this.path, '.homeybuild'); this._homeyComposePath = path.join(this.path, '.homeycompose'); this._exiting = false; this._std = {}; this._git = new GitCommands(appPath); } static usesTypeScript({ appPath }) { const pkgPath = path.join(appPath, 'package.json'); try { const pkg = fse.readJSONSync(pkgPath); return Boolean(pkg && pkg.devDependencies && pkg.devDependencies.typescript); } catch (error) { // Ignore } return false; } static usesModules({ appPath }) { const pkgPath = path.join(appPath, 'package.json'); try { const pkg = fse.readJSONSync(pkgPath); return Boolean(pkg && pkg.type === 'module'); } catch (error) { // Ignore } return false; } static async transpileToTypescript({ appPath }) { Log.success('Typescript detected. Compiling...'); try { let tsconfig; try { const { stdout } = await exec('npx tsc --showConfig'); tsconfig = JSON.parse(stdout); } catch (error) { throw new Error( 'Tsconfig validation failed: unable to read configuration from `npx tsc --showConfig`.', ); } const actualOutDir = tsconfig.compilerOptions?.outDir; const expectedOutDir = './.homeybuild'; if (actualOutDir !== expectedOutDir) { throw new Error( `Expected \`outDir\` to be \`${expectedOutDir}\`, but found \`${actualOutDir || 'undefined'}\``, ); } await exec('npm run build', { cwd: appPath }); Log.success('Typescript compilation successful'); } catch (err) { Log.error('Error occurred while running tsc'); if (err instanceof Error) { Log(err.message); } else { Log(err.stdout); } throw new Error('Typescript compilation failed.'); } } static async monitorCtrlC(callback) { process.once('SIGINT', callback); // CTRL+C process.once('SIGQUIT', callback); // Keyboard quit process.once('SIGTERM', callback); // `kill` command } async _getLocalFileResponse({ serverPort, assetPath }) { const res = await fetch(`http://localhost:${serverPort}${assetPath}`); const headers = { 'Content-Type': res.headers.get('Content-Type') || undefined, 'X-Homey-Hash': res.headers.get('X-Homey-Hash') || undefined, }; const body = Buffer.from(await res.arrayBuffer()); return { status: res.status, headers, body, }; } async _uploadBuildArchive({ url, method, headers, archiveStream, size }) { const response = await fetch(url, { method, headers: { 'Content-Length': size, ...headers, }, body: archiveStream, duplex: 'half', }); if (!response.ok) { throw new Error(response.statusText); } } async validate({ level = 'debug' } = {}) { await this._validate({ level }); } async _validate({ level = 'debug' } = {}) { Log.success('Validating app...'); try { const validator = new HomeyLibApp(this._homeyBuildPath); await validator.validate({ level }); Log.success(`App validated successfully against level \`${level}\``); return true; } catch (err) { Log.error(`App did not validate against level \`${level}\`:`); throw new Error(err.message); } } async build() { Log.success('Building app...'); await this.preprocess(); const valid = await this._validate(); if (valid !== true) throw new Error('The app is not valid, please fix the validation issues first.'); Log.success('App built successfully'); } async run({ clean = false, remote = false, skipBuild = false, linkModules = '', network, dockerSocketPath, } = {}) { const homey = await AthomApi.getActiveHomey(); // Homey Cloud does not support running apps remotely. if (homey.platform === 'cloud' && remote === true) { throw new Error( 'Homey Cloud does not support running apps remotely. Try again without --remote.', ); } // Force remote for Homey Pro (2016 — 2019) if (homey instanceof HomeyAPIV2) { remote = true; } if (remote) { return this.runRemote({ homey, clean, skipBuild, dockerSocketPath, }); } return this.runDocker({ homey, clean, skipBuild, linkModules, network, dockerSocketPath, }); } async runRemote({ homey, clean, skipBuild, dockerSocketPath, findLinks }) { homey.devkit.on('std', this._onStd.bind(this)); homey.devkit.on('disconnect', () => { Log.error('Connection has been lost, attempting to reconnect...'); // reconnect event isn't forwarded from athom api homey.devkit.once('connect', () => { Log.success('Connection restored, some logs might be missing'); }); }); await homey.devkit.connect(); this._session = await this.install({ homey, clean, skipBuild, debug: true, dockerSocketPath, findLinks, }); if (clean) { Log.warning('Purged all Homey App settings'); } Log.success(`Running \`${this._session.appId}\`, press CTRL+C to quit`); Log.info( ` — Profile your app's performance at https://go.athom.com/app-profiling?homey=${homey.id}&app=${this._session.appId}`, ); Log('─────────────── Logging stdout & stderr ───────────────'); App.monitorCtrlC(this._onCtrlC.bind(this)); } async buildForLocalRunner(skipBuild) { if (skipBuild) { Log(colors.yellow('\n⚠ Skipping build steps!\n')); } else { await this.preprocess(); } } static collectRunnerEnv(inspectPort) { return { HOMEY_APP_RUNNER_DEVMODE: process.env.HOMEY_APP_RUNNER_DEVMODE === '1', HOMEY_APP_RUNNER_PATH: process.env.HOMEY_APP_RUNNER_PATH, // e.g. /Users/username/Git/homey-app-runner/src HOMEY_APP_RUNNER_CMD: ['node', `--inspect=0.0.0.0:${inspectPort}`, 'index.js'], HOMEY_APP_RUNNER_ID: process.env.HOMEY_APP_RUNNER_ID || 'ghcr.io/athombv/homey-app-runner:latest', HOMEY_APP_RUNNER_SDK_PATH: process.env.HOMEY_APP_RUNNER_SDK_PATH, // e.g. /Users/username/Git/node-homey-apps-sdk-v3 }; } async runDocker({ homey, clean, skipBuild, linkModules, network, dockerSocketPath, findLinks }) { // Prepare Docker const docker = await DockerHelper.ensureDocker({ dockerSocketPath }); // Build the App await this.buildForLocalRunner(skipBuild, { dockerSocketPath, findLinks }); // Validate the App const valid = await this._validate(); if (valid !== true) throw new Error('Not installing, please fix the validation issues first'); const manifest = App.getManifest({ appPath: this.path }); // Install the App Log.success('Creating Remote Session...'); const { sessionId } = await homey.devkit .installApp({ clean, manifest, }) .catch((err) => { if (err.cause && err.cause.error) { err.message = err.cause.error; } throw err; }); const baseUrl = await homey.baseUrl; const socketUrl = `${baseUrl}/devkit`; // Find Inspect Port const inspectPort = await getPort({ port: getPort.makeRange(9229, 9229 + 100), }); // Get Environment Variables Log.success('Preparing Environment Variables...'); const env = await this._getEnv(); if (Object.keys(env).length) { Log.info(' — Homey.env (env.json)'); Object.keys(env).forEach((key) => { const value = env[key]; Log.info(` — ${key}=${Util.ellipsis(value)}`); }); } let cleanupPromise; const cleanup = async () => { if (!cleanupPromise) { cleanupPromise = Promise.resolve().then(async () => { Log('───────────────────────────────────────────────────────'); await Promise.all([ // Uninstall the App Promise.resolve().then(async () => { Log.success(`Uninstalling \`${manifest.id}\`...`); try { await homey.devkit.uninstallApp({ sessionId }); Log.success(`Uninstalled \`${manifest.id}\``); } catch (err) { Log.error('Error Uninstalling:', err.message || err.toString()); } }), // Delete the Container DockerHelper.deleteContainerBySessionId(sessionId), ]).catch((err) => { Log.error(err.message || err.toString()); }); }); } return cleanupPromise; }; // Delete already existing containers await DockerHelper.deleteContainerByManifestAppId(manifest.id); // Monitor CTRL+C let exiting = false; App.monitorCtrlC(() => { if (exiting) { process.exit(1); } exiting = true; cleanup() .catch(() => {}) .finally(() => { process.exit(0); }); }); const serverPort = await getPort({ port: getPort.makeRange(30000, 40000), }); const serverApp = express(); const serverHTTP = http.createServer(serverApp); // Proxy Icons, add a X-Homey-Hash header serverApp.get('*.svg', (req, res, next) => { Util.getFileHash(path.join(this._homeyBuildPath, req.path)) .then((hash) => { res.header('X-Homey-Hash', hash); next(); }) .catch((err) => { if (err.code === 'ENOENT') { res.status(404); res.end(`Not Found: ${req.path}`); } else { res.status(400); res.end(err.message || err.toString()); } }); }); // Proxy local assets const middlewares = {}; // During development with docker we get the widget public files from the source folder so that // the app does not have to be restarted when the widget files change. Making a change and // reloading the widget should fetch the new file. serverApp.use('/widgets/:widgetId/public', (req, res, next) => { const widgetId = req.params.widgetId; if (!middlewares[widgetId]) { const widgetPath = path.join(this.path, 'widgets', widgetId, 'public'); middlewares[widgetId] = express.static(widgetPath); } return middlewares[widgetId](req, res, next); }); serverApp.use('/', express.static(this._homeyBuildPath)); // Start the HTTP Server await new Promise((resolve, reject) => { serverHTTP.listen(serverPort, (err) => { if (err) return reject(err); return resolve(); }); }); // Start Socket.IO ServerIO & clientIO // The app inside Docker talks to 'serverIO' // The 'clientIO' talks to Homey Log.success(`Connecting to \`${homey.name}\`...`); let homeyIOResolve; let homeyIOReject; const homeyIOPromise = new Promise((resolve, reject) => { homeyIOResolve = resolve; homeyIOReject = reject; }); const clientIO = await new Promise((resolve, reject) => { const clientIO = SocketIOClient(socketUrl, { transports: ['websocket'], }); clientIO .on('connect', () => { resolve(clientIO); }) .on('connect_error', (err) => { Log.error(`Error connecting to \`${homey.name}\``); Log.error(err); reject(err); }) .on('error', reject) .on('disconnect', () => { Log.error(`Disconnected from \`${homey.name}\``); cleanup() .catch() .finally(() => { process.exit(); }); }) .on('event', ({ event, data }, callback) => { homeyIOPromise .then((homeyIO) => { homeyIO.emit( 'event', { homeyId: homey.id, event, data, }, callback, ); }) .catch((err) => callback(err)); }) .on('getFile', ({ path }, callback) => { Promise.resolve() .then(() => this._getLocalFileResponse({ serverPort, assetPath: path })) .then((result) => callback(null, result)) .catch((error) => callback(error.message || error.toString())); }) .on('getImage', ({ ...args }, callback) => { homeyIOPromise .then((homeyIO) => { homeyIO.emit( 'getImage', { homeyId: homey.id, ...args, }, callback, ); }) .catch((err) => callback(err)); }); }); const serverIO = SocketIOServer(serverHTTP, { transports: ['websocket'], reconnect: false, pingTimeout: 10000, pingInterval: 30000, maxHttpBufferSize: 10e6, }); serverIO.on('connection', (socket) => { socket .on('event', ({ homeyId, ...props }, callback) => { if (homeyId !== homey.id) { return callback('Invalid Homey ID'); } // Override 'Homey.api.getLocalUrl'. // Homey Pro returns the Docker-host, but Homey CLI is not running on the same machine. if ( props.type === 'request' && props.uri === 'homey:manager:api' && props.event === 'getLocalUrl' ) { return homey.baseUrl .then((result) => callback(null, result)) .catch((err) => callback(err)); } return clientIO.emit( 'event', { sessionId, ...props, }, callback, ); }) .emit( 'createClient', { homeyId: homey.id, homeyVersion: homey.version, homeyPlatform: homey.platform, homeyPlatformVersion: homey.platformVersion, homeyPlatformFeatures: HomeyLibUtil.getPlatformLocalFeatures(homey.model), homeyLanguage: homey.language, }, (err) => { if (err) { Log.error('App Crashed. Stack Trace:'); Log.error(err); exiting = true; cleanup() .catch(() => {}) .finally(() => { process.exit(0); }); return homeyIOReject(err); } return homeyIOResolve(socket); }, ); }); // Add Icon Hashes to Manifest // App Icon Hash manifest.iconHash = await Util.getFileHash(path.join(this.path, 'assets', 'icon.svg')); // Driver Icon Hashes if (Array.isArray(manifest.drivers)) { await Promise.all( manifest.drivers.map(async (driver) => { const iconPath = path.join(this.path, 'drivers', driver.id, 'assets', 'icon.svg'); if (await fse.pathExists(iconPath)) { driver.iconHash = await Util.getFileHash(iconPath); } }), ); } // Capability Icon Hashes if (manifest.capabilities) { await Promise.all( Object.values(manifest.capabilities).map(async (capability) => { if (capability.icon) { const iconPath = path.join(this.path, capability.icon); capability.iconHash = await Util.getFileHash(iconPath); } }), ); } // Settings if (await fse.pathExists(path.join(this.path, 'settings', 'index.html'))) { manifest.hasSettings = true; } // Start the App on Homey Log.success(`Starting \`${manifest.id}@${manifest.version}\` remotely...`); await Promise.race([ new Promise((resolve, reject) => { clientIO.emit( 'start', { sessionId, manifest, homeyId: homey.id, appId: manifest.id, }, (err) => { if (err) return reject(new Error(err)); return resolve(); }, ); }), new Promise((_, reject) => { setTimeout(() => { reject(new Error('App Start Timeout From Homey')); }, 10000); }), ]); const tmpDir = path.join(os.tmpdir(), 'apps-tmp', manifest.id); if (homey.platform === 'local') { await fse.ensureDir(tmpDir); await fse.emptyDir(tmpDir); fse.watch(tmpDir, (_, filename) => { Log.info(`Modified: ${path.join(tmpDir, filename)}`); }); } let userdataDirWarned = false; const userdataDir = path.join(Settings.getSettingsDirectory(), 'apps-userdata', manifest.id); if (homey.platform === 'local') { // Ensure the directory exists await fse.ensureDir(userdataDir); // Empty userdata when --clean is set if (clean === true) { await fse.emptyDir(userdataDir); } // Watch /userdata/ for changes, and notify the user the files might become out of sync. fse.watch(userdataDir, (_, filename) => { if (userdataDirWarned === false) { userdataDirWarned = true; Log.warning( 'Warning: The /userdata folder is not synced with Homey Pro while developing.\nAfter running the app from the Homey App Store, your /userdata may be out of sync.', ); } Log.info(`Modified: ${path.join(userdataDir, filename)}`); }); // Mount /userdata/ to webserver serverApp.use('/userdata/', express.static(userdataDir)); } // Create & Run Container await this.startRunnerContainer( sessionId, manifest, env, serverPort, inspectPort, network, homey, tmpDir, userdataDir, linkModules, docker, ); await cleanup(); process.exit(0); } async startRunnerContainer( sessionId, manifest, env, serverPort, inspectPort, network, homey, tmpDir, userdataDir, linkModules, docker, ) { const { HOMEY_APP_RUNNER_DEVMODE, HOMEY_APP_RUNNER_PATH, HOMEY_APP_RUNNER_CMD, HOMEY_APP_RUNNER_ID, HOMEY_APP_RUNNER_SDK_PATH, } = App.collectRunnerEnv(inspectPort); // Download Image (if there is no local override) if (!process.env.HOMEY_APP_RUNNER_ID) { // Check if the image exists, or needs refresh pull if ( !(await DockerHelper.imageExists(HOMEY_APP_RUNNER_ID)) || (await DockerHelper.imageNeedPull(HOMEY_APP_RUNNER_ID)) ) { await DockerHelper.imagePull(HOMEY_APP_RUNNER_ID); } } const host = await DockerHelper.determineHost(); const containerEnv = [ 'APP_PATH=/app', `APP_ENV=${JSON.stringify(env)}`, `SERVER=ws://${host}:${serverPort}`, 'DEBUG=1', ]; if (HOMEY_APP_RUNNER_DEVMODE) { containerEnv.push('DEVMODE=1'); } const containerBinds = [`${this._homeyBuildPath}:/app:ro,z`]; if (HOMEY_APP_RUNNER_PATH !== undefined) { containerBinds.push(`${HOMEY_APP_RUNNER_PATH}:/homey-app-runner:ro,z`); } if (HOMEY_APP_RUNNER_SDK_PATH !== undefined) { containerBinds.push( `${HOMEY_APP_RUNNER_SDK_PATH}:/homey-app-runner/node_modules/@athombv/homey-apps-sdk-v3:ro,z`, ); } // Mount /userdata & /tmp for platform local if (homey.platform === 'local') { containerBinds.push(`${tmpDir}:/tmp:rw,z`, `${userdataDir}:/userdata:rw,z`); } // Link Node.js modules as Docker binds. // Note that we need to read the `name` from the module's package.json to create a correct path. containerBinds.push( `${path.join(this._homeyBuildPath, 'node_modules')}:/app/node_modules/:rw,z`, ...linkModules .split(',') .filter((linkModule) => !!linkModule) .map((linkModule) => { const linkedModulePath = linkModule.trim(); const { name } = fse.readJSONSync(path.join(linkedModulePath, 'package.json')); return `${linkedModulePath}:/app/node_modules/${name}`; }), ); const createOpts = { name: `homey-app-runner-${sessionId}-${manifest.id}-v${manifest.version}`, Env: containerEnv, ExposedPorts: { [`${inspectPort}/tcp`]: {}, }, Labels: { 'com.athom.session': sessionId, 'com.athom.port': String(serverPort), 'com.athom.app-id': manifest.id, 'com.athom.app-version': manifest.version, 'com.athom.app-runtime': manifest.runtime, }, HostConfig: { ReadonlyRootfs: true, NetworkMode: network, PortBindings: { [`${inspectPort}/tcp`]: [ { HostPort: String(inspectPort), }, ], }, Binds: containerBinds, }, }; Log.success(`Starting debugger at 0.0.0.0:${inspectPort}...`); Log.info(' — Open `about://inspect` in Google Chrome and select the remote target.'); Log.success(`Starting \`${manifest.id}@${manifest.version}\` in a Docker container...`); Log.info(' — Press CTRL+C to quit.'); Log('─────────────── Logging stdout & stderr ───────────────'); const passThrough = new stream.PassThrough(); passThrough.pipe(process.stdout); // On Raspberry Pi, an outdated libseccomp crashes the container. // Help the developer by letting them know to upgrade. if (process.platform === 'linux') { passThrough.on('data', (chunk) => { chunk = chunk.toString(); if (chunk.includes('# Fatal error in , line 0')) { setTimeout(() => { Log.error(` Oops! Node.js inside Docker has crashed. This is a known issue due to an outdated package on Linux. To fix, simply run: $ wget http://ftp.debian.org/debian/pool/main/libs/libseccomp/libseccomp2_2.5.3-2_armhf.deb $ sudo dpkg -i libseccomp2_2.5.3-2_armhf.deb $ rm libseccomp2_2.5.3-2_armhf.deb $ sudo systemctl restart docker `); }, 1000); } }); } await docker.run(HOMEY_APP_RUNNER_ID, HOMEY_APP_RUNNER_CMD, passThrough, createOpts); } async install({ homey, clean = false, skipBuild = false, debug = false } = {}) { if (homey.platform === 'cloud') { throw new Error( 'Installing apps is not available on Homey Cloud.\nPlease run your app instead.', ); } if (skipBuild) { Log(colors.yellow('\n⚠ Skipping build steps!\n')); } else { await this.preprocess(); } const valid = await this._validate(); if (valid !== true) throw new Error('Not installing, please fix the validation issues first'); Log.success('Packing Homey App...'); const app = await this._getPackStream({ appPath: skipBuild ? this.path : this._homeyBuildPath, }); const env = await this._getEnv(); Log.success(`Installing Homey App on \`${homey.name}\` (${await homey.baseUrl})...`); try { const result = await homey.devkit.runApp({ app, env, debug, clean, }); Log.success(`Homey App \`${result.appId}\` successfully installed`); return result; } catch (err) { Log.error(err); process.exit(); } } async preprocess({ copyAppProductionDependencies = true } = {}) { if (App.hasHomeyCompose({ appPath: this.path }) === false) { // Note: this checks that we are in a valid homey app folder App.getManifest({ appPath: this.path }); } Log.success('Pre-processing app...'); // Build app.json from Homey Compose files if (App.hasHomeyCompose({ appPath: this.path })) { const usesModules = App.usesModules({ appPath: this.path }); await HomeyCompose.build({ appPath: this.path, usesModules }); } // Clear the .homeybuild/ folder await fse.remove(this._homeyBuildPath).catch(async (err) => { // It helps to wait a bit when ENOTEMPTY is thrown. if (err.code === 'ENOTEMPTY') { await new Promise((resolve) => setTimeout(resolve, 2000)); return fse.remove(this._homeyBuildPath); } throw err; }); // Copy app source over to .homeybuild/ await this._copyAppSourceFiles(); // Copy production dependencies to .homeybuild/ if (copyAppProductionDependencies) { await this._copyAppProductionDependencies(); } // Compile TypeScript files to .homeybuild/ if (App.usesTypeScript({ appPath: this.path })) { await App.transpileToTypescript({ appPath: this.path }); } const appJsonPath = path.join(this.path, 'app.json'); // Read app.json file as json. try { const appJsonDataRaw = await fs.promises.readFile(appJsonPath, 'utf-8'); const appJsonData = JSON.parse(appJsonDataRaw); // Ensure package.json contains type: module if (appJsonData.esm === true) { const packageJsonPath = path.join(this.path, 'package.json'); try { const packageJsonDataRaw = await fs.promises.readFile(packageJsonPath, 'utf-8'); const packageJsonData = JSON.parse(packageJsonDataRaw); packageJsonData.type = 'module'; // Write to build folder. await fs.promises.writeFile( path.join(this._homeyBuildPath, 'package.json'), JSON.stringify(packageJsonData, null, 2), ); } catch (err) { Log.error('Error reading the file', err); } } } catch (err) { Log.error('Error reading the file', err); } // Ensure `/.homeybuild` is added to `.gitignore`, if it exists const gitIgnorePath = path.join(this.path, '.gitignore'); if (await fse.pathExists(gitIgnorePath)) { const gitIgnore = await fse.readFile(gitIgnorePath, 'utf8'); if (!gitIgnore.includes('.homeybuild')) { Log.success('Automatically added `/.homeybuild/` to .gitignore'); await fse.writeFile(gitIgnorePath, `${gitIgnore}\n\n# Added by Homey CLI\n/.homeybuild/`); } } } async _copyAppSourceFiles() { const sourceFiles = await this._getAppSourceFiles(); for (const filePath of sourceFiles) { const fullSrc = path.join(this.path, filePath); const fullDest = path.join(this._homeyBuildPath, filePath); await fse.copy(fullSrc, fullDest); } const appJson = await fs.promises.readFile(path.join(this.path, 'app.json')).then((data) => { return JSON.parse(data); }); if (appJson.widgets) { for (const [widgetId] of Object.entries(appJson.widgets)) { const previewLightPath = path.join(this.path, 'widgets', widgetId, 'preview-light.png'); const previewDarkPath = path.join(this.path, 'widgets', widgetId, 'preview-dark.png'); // eslint-disable-next-line no-useless-catch try { await fs.promises.access(previewLightPath); await fs.promises.access(previewDarkPath); const imageLight = sharp(previewLightPath); const imageDark = sharp(previewDarkPath); await fs.promises.mkdir( path.join(this._homeyBuildPath, 'widgets', widgetId, '__assets__'), { recursive: true }, ); await Promise.all([ fs.promises.copyFile( previewLightPath, path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-light.png', ), ), imageLight .resize(128, 128) .toFile( path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-light@1x.png', ), ), imageLight .resize(192, 192) .toFile( path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-light@1.5x.png', ), ), imageLight .resize(256, 256) .toFile( path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-light@2x.png', ), ), imageLight .resize(384, 384) .toFile( path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-light@3x.png', ), ), imageLight .resize(512, 512) .toFile( path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-light@4x.png', ), ), fs.promises.copyFile( previewDarkPath, path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-dark.png', ), ), imageDark .resize(128, 128) .toFile( path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-dark@1x.png', ), ), imageDark .resize(192, 192) .toFile( path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-dark@1.5x.png', ), ), imageDark .resize(256, 256) .toFile( path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-dark@2x.png', ), ), imageDark .resize(384, 384) .toFile( path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-dark@3x.png', ), ), imageDark .resize(512, 512) .toFile( path.join( this._homeyBuildPath, 'widgets', widgetId, '__assets__', 'preview-dark@4x.png', ), ), ]); } catch (error) { throw error; } } } } async _copyAppProductionDependencies() { const hasNodeModules = fs.existsSync(path.join(this.path, 'node_modules')); const hasPackageJSON = fs.existsSync(path.join(this.path, 'package.json')); fse.ensureDirSync(path.join(this._homeyBuildPath, 'node_modules')); if (hasNodeModules === true && hasPackageJSON === false) { // `npm ls` (in getProductionDependencies) needs a package.json to list dependencies // If an app has a node_modules folder but no pacakge.json we just copy it wholesale. const src = path.join(this.path, 'node_modules'); const dest = path.join(this._homeyBuildPath, 'node_modules'); await fse.copy(src, dest); return; } const dependencies = await NpmCommands.getProductionDependencies({ appPath: this.path }).catch( (error) => { Log.error(error.message); throw new Error('This error may be fixed by running `npm install` in your app.'); }, ); for (const filePath of dependencies) { const fullSrc = path.join(this.path, filePath); const fullDest = path.join(this._homeyBuildPath, filePath); await fse.copy(fullSrc, fullDest, { filter(src) { // Do not copy node_modules of dependencies, if we need a sub-dependency it // will itself be listed by `NpmCommands.getProductionDependencies()` const subPath = src.replace(fullSrc, ''); // The first character is either `/` or `\` so we start looking at position 1 return subPath.startsWith('node_modules', 1) === false; }, }); } // Overwrite the `node_modules/homey` module with the one from the CLI. This is so that apps can // import ... from 'homey'; fse.ensureDirSync(path.join(this._homeyBuildPath, 'node_modules', 'homey')); fse.emptyDirSync(path.join(this._homeyBuildPath, 'node_modules', 'homey')); const homeyModulePath = path.join(__dirname, '..', 'assets', 'homey'); await fse.copy(homeyModulePath, path.join(this._homeyBuildPath, 'node_modules', 'homey')); } async version(version) { let manifest; let manifestFolder; if (App.hasHomeyCompose({ appPath: this.path })) { try { // HACK: We trick `getManifest` to look into the wrong folder to // read and validate the manifest. manifest = App.getComposeManifest({ appPath: this.path }); manifestFolder = path.join(this.path, '.homeycompose'); } catch (error) { // .homeycompose/app.json is optional, you can use a root regular app.json } } if (!manifest) { manifest = App.getManifest({ appPath: this.path }); manifestFolder = this.path; } const prevVersion = manifest.version; if (semver.valid(version)) { manifest.version = semver.valid(version); } else if (['minor', 'major', 'patch'].includes(version)) { manifest.version = semver.inc(manifest.version, version); } else { throw new Error('Invalid version. Must be either patch, minor or major.'); } await writeFileAsync(path.join(manifestFolder, 'app.json'), JSON.stringify(manifest, false, 2)); // Build app.json from Homey Compose files if (App.hasHomeyCompose({ appPath: this.path })) { await HomeyCompose.build({ appPath: this.path }); } Log.success(`Updated app.json version to \`${manifest.version}\``); const undo = async () => { manifest.version = prevVersion; await writeFileAsync( path.join(manifestFolder, 'app.json'), JSON.stringify(manifest, false, 2), ); // Build app.json from Homey Compose files if (App.hasHomeyCompose({ appPath: this.path })) { await HomeyCompose.build({ appPath: this.path }); } }; return undo; } async changelog(text) { const changelogJsonPath = path.join(this.path, '.homeychangelog.json'); const changelogJson = (await fse.pathExists(changelogJsonPath)) ? await fse.readJson(changelogJsonPath) : {}; const manifest = App.getManifest({ appPath: this.path }); const { version } = manifest; changelogJson[version] = changelogJson[version] || {}; if (typeof text === 'string') { changelogJson[version]['en'] = text; } else if (typeof text === 'object') { const validLocales = getAppLocales(); for (const [languageCode, changelog] of Object.entries(text)) { if (!validLocales.includes(languageCode)) { throw new Error( `Invalid language code: ${languageCode}. Valid codes are: ${validLocales.join(', ')}`, ); } changelogJson[version][languageCode] = changelog; } } else { throw new Error('Invalid changelog format. Must be a string or an object.'); } await fse.writeJson(changelogJsonPath, changelogJson, { spaces: 2, }); Log.success(`Updated changelog for version \`${version}\``); } async commit(changelog) { if ( (await GitCommands.isGitInstalled()) && (await GitCommands.isGitRepo({ path: this.path })) ) { // Check whether only app.json or also .homeycompose/app.json needs to be committed const commitFiles = []; commitFiles.push(path.join(this.path, 'app.json')); if (await fse.exists(path.join(this._homeyComposePath, 'app.json'))) { commitFiles.push(path.join(this._homeyComposePath, 'app.json')); } // Retrieve updated version const { version } = App.getManifest({ appPath: this.path }); const hasChangelog = !!changelog; if (hasChangelog) { commitFiles.push(path.join(this.path, '.homeychangelog.json')); if (typeof changelog === 'string') { changelog = { en: changelog }; } } else { // Retrieve from changelog file const changelogJsonPath = path.join(this.path, '.homeychangelog.json'); const changelogJson = (await fse.pathExists(changelogJsonPath)) ? await fse.readJson(changelogJsonPath) : {}; changelog = changelogJson[version]; } // Commit the changes await this._commitChanges(true, hasChangelog, commitFiles, version, changelog, true); } else { throw new Error( 'A git executable or repository was not found, could not commit version bump', ); } } async _commitChanges( bumpedVersion, updatedChangelog, commitFiles, appVersion, changelog, skipCommitQuestion = false, ) { let createdGitTag = false; // Only commit and tag if version is bumped if (bumpedVersion) { // First ask if version bump is desired const shouldCommit = skipCommitQuestion || (await inquirer.prompt([ { type: 'confirm', name: 'value', message: `Do you want to commit the version bump ${updatedChangelog ? 'and updated changelog' : ''}?`, default: true, }, ])); // Check if commit is desired if (skipCommitQuestion || shouldCommit.value) { // If version is bumped via wizard and changelog is changed via wizard // then commit all at once if (updatedChangelog) { await this._git.commitFiles({ files: commitFiles, message: `Bump version to v${appVersion}`, description: `Changelog: ${changelog['en']}`, }); Log.success( `Committed ${commitFiles .map((i) => i.replace(`${this.path}/`, '')) .join(', and ')} with version bump`, ); } else { await this._git.commitFiles({ files: commitFiles, message: `Bump version to v${appVersion}`, }); Log.success( `Committed ${commitFiles .map((i) => i.replace(`${this.path}/`, '')) .join(', and ')} with version bump`, ); } try { if (await this._git.hasUncommittedChanges()) { throw new Error('There are uncommitted or untracked files in this git repository'); } await this._git.createTag({ version: appVersion, message: changelog['en'], }); Log.success(`Successfully created Git tag \`${appVersion}\``); createdGitTag = true; } catch (error) { Log.warning(`Warning: could not create git tag (v${appVersion}), reason:`); Log.info(error); } } } if ((await this._git.hasRemoteOrigin()) && bumpedVersion) { const answers = await inquirer.prompt([ { type: 'confirm', name: 'push', message: 'Do you want to push the local changes to `remote "origin"`?', default: false, }, ]); if (answers.push) { // First push tag if (createdGitTag) await this._git.pushTag({ version: appVersion }); // Push all staged changes await this._git.push(); Log.success('Successfully pushed changes to remote.'); } } } async publish({ findLinks, dockerSocketPath } = {}) { const undos = { version: null, }; try { const env = await this._getEnv(); if ( (await GitCommands.isGitInstalled()) && (await GitCommands.isGitRepo({ path: this.path })) ) { if ((await this._git.hasUncommittedChanges()) && process.env.HOMEY_HEADLESS !== '1') { const { shouldContinue } = await inquirer.prompt([ { type: 'confirm', name: 'shouldContinue', message: 'There are uncommitted changes. Are you sure you want to continue?', default: false, }, ]); if (!shouldContinue) return; } } if (process.env.HOMEY_HEADLESS !== '1') { Log(''); Log.info('Before publishing, please review the Homey App Store guidelines:'); Log.info('https://apps.developer.homey.app/app-store/guidelines'); Log(''); const { hasReadGuidelines } = await inquirer.prompt([ { type: 'confirm', name: 'hasReadGuidelines', message: 'I have read the Homey App Store guidelines', default: false, }, ]); if (!hasReadGuidelines) return; } let manifest = App.getManifest({ appPath: this.path }); try { manifest = App.getComposeManifest({ appPath: this.path }); } catch (error) { // Log.error(error); } const { id: appId, name: appName } = manifest; let { version: appVersion } = manifest; const versionBumpChoices = { patch: { value: 'patch', targetVersion: `${semver.inc(appVersion, 'patch')}`, get name() { return `Patch (to v${this.targetVersion})`; }, }, minor: { value: 'minor', targetVersion: `${semver.inc(appVersion, 'minor')}`, get name() { return `Minor (to v${this.targetVersion})`; }, }, major: { value: 'major', targetVersion: `${semver.inc(appVersion, 'major')}`, get name() { return `Major (to v${this.targetVersion})`; }, }, }; // First ask if version bump is desired const shouldUpdateVersion = process.env.HOMEY_HEADLESS === '1' ? { value: false } : await inquirer.prompt([ { type: 'confirm', name: 'value', message: `Do you want to update your app's version number? (current v${appVersion})`, default: true, }, ]); let shouldUpdateVersionTo = null; // If version bump is desired ask for patch/minor/major if (shouldUpdateVersion.value) { shouldUpdateVersionTo = await inquirer.prompt([ { type: 'list', name: 'version', message: 'Select the desired version number', choices: Object.values(versionBumpChoices), }, ]); } let bumpedVersion = false; const commitFiles = []; if (shouldUpdateVersion.value) { // Apply new version (this changes app.json and .homeycompose/app.json if needed) undos.version = await this.version(shouldUpdateVersionTo.version); // Check if only app.json or also .homeycompose/app.json needs to be committed commitFiles.push(path.join(this.path, 'app.json')); if (await fse.exists(path.join(this._homeyComposePath, 'app.json'))) { commitFiles.push(path.join(this._homeyComposePath, 'app.json')); } // Update version number appVersion = versionBumpChoices[shouldUpdateVersionTo.version].targetVersion; // Set flag to know that we have changed the version number bumpedVersion = true; } await this.preprocess({ findLinks, dockerSocketPath }); const profile = await AthomApi.getProfile(); const level = profile.roleIds.includes('app_developer_trusted') ? 'verified' : 'publish'; const valid = await this._validate({ level }); if (valid !== true) throw new Error('The app is not valid, please fix the validation issues first.'); delete undos.version; // Get or create changelog let updatedChangelog = false; const changelog = await Promise.resolve().then(async () => { const changelogJsonPath = path.join(this.path, '.homeychangelog.json'); const changelogJson = (await fse.pathExists(changelogJsonPath)) ? await fse.readJson(changelogJsonPath) : {}; if (!changelogJson[appVersion] || !changelogJson[appVersion]['en']) { if (process.env.HOMEY_HEADLESS === '1') { throw new Error(`Missing changelog for v${appVersion}, and running in headless mode.`); } const { text } = await inquirer.prompt([ { type: 'input', name: 'text', message: `(Changelog) What's new in ${appName.en} v${appVersion}?`, validate: (input) => { return input.length > 3; }, }, ]); changelogJson[appVersion] = changelogJson[appVersion] || {}; changelogJson[appVersion]['en'] = text; await fse.writeJson(changelogJsonPath, changelogJson, { spaces: 2, }); Log.info(` — Changelog: ${text}`); // Mark as changed updatedChangelog = true; // Make sure to commit changelog changes commitFiles.push(changelogJsonPath); } return changelogJson[appVersion]; }); // Get readme const en = await readFileAsync(path.join(this.path, 'README.txt')) .then((buf) => buf.toString()) .catch((err) => { throw new Error( 'Missing file `/README.txt`. Please provide a README for your app. The contents of this file will be visible in the App Store.', ); }); const readme = { en }; // Read files in app dir const files = await readDirAsync(this.path, { withFileTypes: true }); // Loop all paths to check for matching readme names for (const file of files) { if (Object.prototype.hasOwnProperty.call(file, 'name') && typeof file.name === 'string') { // Check for README.<code>.txt file name if (file.name.startsWith('README.') && file.name.endsWith('.txt')) { const languageCode = file.name.replace('README.', '').replace('.txt', ''); // Check language code against homey-lib supported language codes if (getAppLocales().includes(languageCode)) { // Read contents of file into readme object readme[languageCode] = await readFileAsync(path.join(this.path, file.name)).then( (buf) => buf.toString(), ); } } } } // Get delegation token Log.success(`Submitting ${appId}@${appVersion}...`); if (Object.keys(env).length) { Log.info(' — Homey.env (env.json)'); Object.keys(env).forEach((key) => { const value = env[key]; Log.info(` — ${key}=${Util.ellipsis(value)}`); }); } const athomAppsApi = new AthomAppsAPI(); const { url, method, headers, buildId } = await athomAppsApi.createBuild({ $token: await AthomApi.createDelegationToken({ audience: 'apps', }), env, appId, changelog, version: appVersion, readme, }); // Make sure archive stream is created after any additional changes to the app // and right bef