homey
Version:
Command-line interface and type declarations for Homey Apps
1,604 lines (1,396 loc) • 118 kB
JavaScript
'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