UNPKG

homey

Version:

Command-line interface and type declarations for Homey Apps

1,045 lines (892 loc) 32.1 kB
'use strict'; const fs = require('fs'); const fsPromises = require('fs/promises'); const path = require('path'); const zlib = require('zlib'); const stream = require('stream'); const { promisify } = require('util'); const { arch } = require('node:process'); const colors = require('colors'); const tmp = require('tmp-promise'); const tar = require('tar-fs'); const ignoreWalk = require('ignore-walk'); const fse = require('fs-extra'); const filesize = require('filesize'); const inquirer = require('inquirer'); const TOML = require('smol-toml'); const HomeyLibApp = require('homey-lib').App; const AthomApi = require('../services/AthomApi'); const Log = require('./Log'); const HomeyCompose = require('./HomeyCompose'); const DockerHelper = require('./DockerHelper'); const App = require('./App'); const StacklessError = require('./StacklessError'); const statAsync = promisify(fs.stat); const mkdirAsync = promisify(fs.mkdir); const writeFileAsync = promisify(fs.writeFile); const copyFileAsync = promisify(fs.copyFile); const pipeline = promisify(stream.pipeline); const PYTHON_PACKAGES_FOLDER = 'python_packages'; const INVALID_PYTHON_MODULE_CHARACTERS = /[^a-z0-9_]/g; const BUILDER_IMAGES = { arm64: 'ghcr.io/athombv/python-homey-app-builder-arm64:latest', amd64: 'ghcr.io/athombv/python-homey-app-builder-amd64:latest', }; class AppPython extends App { static async checkHomeyCompatibility(homey) { if (!(homey.__properties.apiVersion === 3)) { throw new StacklessError('Python apps are currently not supported on Homey Pro (2016—2019)'); } } static usesTypeScript({ appPath }) { throw new Error('Method should not be called for Python: "App.usesTypeScript"'); } static usesModules({ appPath }) { throw new Error('Method should not be called for Python: "App.usesModules"'); } static async transpileToTypescript({ appPath }) { throw new Error('Method should not be called for Python: "App.transpileToTypescript".'); } async build({ findLinks, dockerSocketPath } = {}) { Log.success('Building app...'); await this.preprocess({ findLinks, dockerSocketPath }); 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, findLinks, } = {}) { const homey = await AthomApi.getActiveHomey(); await AppPython.checkHomeyCompatibility(homey); if (remote) { return this.runRemote({ homey, clean, skipBuild, dockerSocketPath, }); } return this.runDocker({ homey, clean, skipBuild, linkModules, network, dockerSocketPath, findLinks, }); } async buildForLocalRunner(skipBuild, { findLinks, dockerSocketPath } = {}) { if (skipBuild) { Log(colors.yellow("\n⚠ Can't skip build step for Python!\n")); } await this.preprocess({ platforms: [AppPython.getLocalPlatform()], dockerSocketPath, findLinks, }); } static collectRunnerEnv() { return { HOMEY_APP_RUNNER_DEVMODE: process.env.HOMEY_APP_RUNNER_DEVMODE === '1', HOMEY_APP_RUNNER_PATH: process.env.HOMEY_APP_RUNNER_PATH_PYTHON, // e.g. /Users/username/Git/homey-app-runner/src HOMEY_APP_RUNNER_CMD: ['bash', 'entrypoint.sh'], HOMEY_APP_RUNNER_ID: process.env.HOMEY_APP_RUNNER_ID_PYTHON || 'ghcr.io/athombv/python-homey-app-runner:latest', HOMEY_APP_RUNNER_SDK_PATH: process.env.HOMEY_APP_RUNNER_SDK_PATH_PYTHON, // e.g. /Users/username/Git/python-homey-sdk-v3/dist }; } 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, } = AppPython.collectRunnerEnv(inspectPort); // Download Image (if there is no local override) if (!process.env.HOMEY_APP_RUNNER_ID_PYTHON) { await DockerHelper.imagePullIfNeeded(HOMEY_APP_RUNNER_ID, AppPython.getLocalPlatform()); } 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`]; await fse.ensureDir(path.join(this._homeyBuildPath, '.venv')); if (HOMEY_APP_RUNNER_PATH !== undefined) { containerBinds.push(`${HOMEY_APP_RUNNER_PATH}:/runner/src:ro,z`); } if (HOMEY_APP_RUNNER_SDK_PATH !== undefined) { containerBinds.push(`${HOMEY_APP_RUNNER_SDK_PATH}:/runner/sdk:ro,z`); } // Mount /userdata & /tmp for platform local if (homey.platform === 'local') { containerBinds.push(`${tmpDir}:/tmp:rw,z`, `${userdataDir}:/userdata:rw,z`); } const createOpts = { name: `homey-app-runner-${sessionId}-${manifest.id}-v${manifest.version}`, Env: containerEnv, 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: { NetworkMode: network, Binds: containerBinds, }, User: process.getuid !== undefined ? `${process.getuid()}:${process.getgid()}` : undefined, }; 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); await docker.run(HOMEY_APP_RUNNER_ID, HOMEY_APP_RUNNER_CMD, passThrough, createOpts); } async install({ homey, clean = false, skipBuild = false, debug = false, dockerSocketPath, findLinks, } = {}) { if (homey.platform === 'cloud') { throw new Error( 'Installing apps is not available on Homey Cloud.\nPlease run your app instead.', ); } await AppPython.checkHomeyCompatibility(homey); if (skipBuild) { Log(colors.yellow("\n⚠ Can't skip build step for Python!\n")); } await this.preprocess({ findLinks, dockerSocketPath }); 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: 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, platforms = HomeyLibApp.REQUIRED_PYTHON_PLATFORMS, dockerSocketPath, findLinks, } = {}) { Log.success('Pre-processing app...'); // Build app.json from Homey Compose files if (App.hasHomeyCompose({ appPath: this.path })) { await HomeyCompose.build({ appPath: this.path, usesModules: false }); } const manifest = App.getManifest({ appPath: this.path }); // 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; }); await this._copyAppSourceFiles(); // Collect production dependencies in ./homeybuild const hasDependencies = manifest.pythonPackages !== undefined && manifest.pythonPackages.length > 0; if (copyAppProductionDependencies && hasDependencies) { let shouldRecompile; // Check whether the Python version has changed in the manifest HomeyLibApp.validatePythonVersion(manifest); const pythonVersion = manifest.pythonVersion; const previousPythonVersion = await fsPromises .readFile(path.join(this.path, PYTHON_PACKAGES_FOLDER, '.python-version'), 'utf8') .catch(() => undefined); shouldRecompile = pythonVersion !== previousPythonVersion; // Check whether pre-compiled venvs exist for all platforms for (const platform of platforms) { const venvCachePath = path.join( this.path, PYTHON_PACKAGES_FOLDER, platform ?? 'local', '.venv', ); shouldRecompile |= !fse.pathExistsSync(venvCachePath); } if (shouldRecompile) { Log.error(`No pre-compiled venvs found for Python ${pythonVersion}`); Log.success('Compiling dependencies...'); await this.installDependencies({ findLinks, dockerSocketPath }); } await this.collectProductionDependencies(platforms); } // 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/`); } } Log.success('Pre-processed app'); } async collectProductionDependencies(platforms) { for (const platform of platforms) { try { const venvCachePath = path.join( this.path, PYTHON_PACKAGES_FOLDER, platform ?? 'local', '.venv', ); const venvBuildPath = platform === undefined ? path.join(this._homeyBuildPath, '.venv') : path.join(this._homeyBuildPath, PYTHON_PACKAGES_FOLDER, platform); await fsPromises.cp(venvCachePath, venvBuildPath, { recursive: true }); // Remove broken symlinks const venvBinPath = path.join(venvBuildPath, 'bin'); const binFiles = fs.readdirSync(venvBinPath); const pythonBinRegex = /^python\d*(?:\.\d+)?$/; const pythonBinFiles = binFiles.filter((value) => pythonBinRegex.test(value)); for (const pythonBinFile of pythonBinFiles) { fs.rmSync(path.join(venvBinPath, pythonBinFile)); } } catch (e) { throw new StacklessError( `Error while collecting cross-compiled virtual environment for ${platform}. Did you run 'homey app dependencies install'?`, ); } } } async _getPackStream({ appPath = this._homeyBuildPath } = {}) { let appSize = 0; let numFiles = 0; const tmpFile = await tmp.file({ postfix: '.tgz' }); // Include just the packages in lib and ignore the rest of the venv const venvLibRegex = RegExp(String.raw`\/${PYTHON_PACKAGES_FOLDER}\/\w+\/lib(?!64)`); // Do not exclude the venv root folders const venvFolderRegex = RegExp(String.raw`\/${PYTHON_PACKAGES_FOLDER}\/\w+$`); await pipeline( tar .pack(appPath, { dereference: true, map(header) { if (header.type === 'file') numFiles += 1; }, ignore(name) { if (name.includes(`/${PYTHON_PACKAGES_FOLDER}/`)) { const isVenvRootFolder = venvFolderRegex.test(name); const isVenvLibFolder = venvLibRegex.test(name); if (isVenvRootFolder) return false; if (isVenvLibFolder) { // Do not pack the homey library, as it will be provided at runtime return name.endsWith('/homey'); } return true; } if (name.endsWith('pyproject.toml') || name.endsWith('uv.lock')) return true; if (name.startsWith('.')) return true; if (name.includes('/.git/')) return true; return false; }, }) .on('data', (chunk) => { appSize += chunk.length; }), zlib.createGzip(), fs.createWriteStream(tmpFile.path), ); Log.info(` — App archive size: ${filesize(appSize)}, ${numFiles} files`); const readFileStream = fs.createReadStream(tmpFile.path); stream.finished(readFileStream, () => { tmpFile.cleanup().catch((error) => { // ignore }); }); return readFileStream; } async _getAppSourceFiles() { const DEFAULT_IGNORE_RULES_FILE = '$default-ignore-rules'; const walker = new ignoreWalk.Walker({ path: this.path, ignoreFiles: ['.homeyignore', DEFAULT_IGNORE_RULES_FILE], includeEmpty: true, follow: true, }); const ignoreRules = [ '.*', '.homeybuild', '.venv', PYTHON_PACKAGES_FOLDER, 'env.json', '*.compose.json', ]; // Add a "file" containing our default ignore rules for dotfiles, env.json and node_modules walker.onReadIgnoreFile(DEFAULT_IGNORE_RULES_FILE, ignoreRules.join('\r\n'), () => {}); const fileEntries = await new Promise((resolve, reject) => { walker.on('done', resolve).on('error', reject).start(); }); return fileEntries; } getDriverIdPrompt(driverName) { return { type: 'input', name: 'driverId', message: "What is your Driver's ID?", default: () => { let name = driverName; name = name.toLowerCase(); name = name.replace(/ /g, '_'); name = name.replace(INVALID_PYTHON_MODULE_CHARACTERS, ''); return name; }, validate: (input) => { if (!input.match(/^[a-z_]/)) { throw new Error('Invalid characters: needs to start with a letter or underscore (_)'); } if (input.match(INVALID_PYTHON_MODULE_CHARACTERS)) { throw new Error('Invalid characters: only use letters, numbers, and underscores (_)'); } if (fs.existsSync(path.join(this.path, 'drivers', input))) { throw new Error('Driver directory already exists!'); } return true; }, }; } async copyDriverAndDeviceTemplate(templatePath, driverPath) { await copyFileAsync(path.join(templatePath, 'driver.py'), path.join(driverPath, 'driver.py')); await copyFileAsync(path.join(templatePath, 'device.py'), path.join(driverPath, 'device.py')); } async installRfLibrary() { throw new Error('homey-rfdriver is not supported for Python yet...'); } async installZigbeeLibrary() { throw new Error('homey-zigbeedriver is not supported for Python yet...'); } async installZWaveLibrary() { throw new Error('homey-zwavedriver is not supported for Python yet...'); } async installOAuth2Library() { throw new Error('homey-oauth2app is not supported for Python yet...'); } async copyWidgetTemplate(widgetPath, widgetName) { await fse.ensureDir(widgetPath); const widgetJson = { name: { en: widgetName }, height: 188, settings: [], api: { get_something: { method: 'GET', path: '/', }, add_something: { method: 'POST', path: '/', }, update_something: { method: 'PUT', path: '/:id', }, delete_something: { method: 'DELETE', path: '/:id', }, }, }; await writeFileAsync( path.join(widgetPath, 'widget.compose.json'), JSON.stringify(widgetJson, false, 2), ); const templatePath = path.join(__dirname, '..', 'assets', 'templates', 'app', 'widgets'); await fse.ensureDir(path.join(widgetPath, 'public')); await copyFileAsync( path.join(templatePath, 'public/index.html'), path.join(widgetPath, 'public/index.html'), ); await copyFileAsync( path.join(templatePath, 'public/homey-logo.png'), path.join(widgetPath, 'public/homey-logo.png'), ); await copyFileAsync(path.join(templatePath, 'api.py'), path.join(widgetPath, 'api.py')); await copyFileAsync( path.join(templatePath, 'preview-dark.png'), path.join(widgetPath, 'preview-dark.png'), ); await copyFileAsync( path.join(templatePath, 'preview-light.png'), path.join(widgetPath, 'preview-light.png'), ); } static async create({ appPath: cwd, globalAnswers }) { const stat = await statAsync(cwd); if (!stat.isDirectory()) { throw new Error('Invalid path, must be a directory'); } const localAnswers = await inquirer.prompt([ { type: 'confirm', name: 'confirm', message: 'Seems good?', default: true, }, ]); // Combine global and local answers const answers = { ...globalAnswers, ...localAnswers, }; if (!answers.confirm) return; const appPath = path.join(cwd, answers.id); // Create the app folder (and fail if it already exists) await mkdirAsync(appPath); const latestSupportedPythonVersion = HomeyLibApp.SUPPORTED_PYTHON_VERSIONS.at(-1); const appJson = { id: answers.id, version: '1.0.0', compatibility: '>=13.0.0', sdk: 3, runtime: 'python', pythonVersion: latestSupportedPythonVersion, platforms: answers.platforms, name: { en: answers.appName }, description: { en: answers.appDescription }, category: [answers.category], permissions: [], images: { small: '/assets/images/small.png', large: '/assets/images/large.png', xlarge: '/assets/images/xlarge.png', }, }; try { const profile = await AthomApi.getProfile(); appJson.author = { name: `${profile.firstname} ${profile.lastname}`, email: profile.email, }; } catch (err) {} // make dirs const dirs = ['locales', 'drivers', 'assets', path.join('assets', 'images')]; for (let i = 0; i < dirs.length; i++) { const dir = dirs[i]; try { await mkdirAsync(path.join(appPath, dir)); } catch (err) { Log(err); } } await HomeyCompose.createComposeDirectories({ appPath }); if (answers['github-workflows']) { Log.error('Github workflows are not supported for Python yet.'); // TODO call once implemented } await writeFileAsync( path.join(appPath, '.homeycompose', 'app.json'), JSON.stringify(appJson, false, 2), ); const generatedAppManifestWarning = { _comment: 'This file is generated. Please edit .homeycompose/app.json instead.', ...appJson, }; await writeFileAsync( path.join(appPath, 'app.json'), JSON.stringify(generatedAppManifestWarning, false, 2), ); await writeFileAsync(path.join(appPath, 'locales', 'en.json'), JSON.stringify({}, false, 2)); await writeFileAsync(path.join(appPath, 'README.txt'), `${appJson.description.en}\n`); // i18n pre-support if (appJson.description.nl) { await writeFileAsync(path.join(appPath, 'README.nl.txt'), `${appJson.description.nl}\n`); } // copy files const templatePath = path.join(__dirname, '..', 'assets', 'templates', 'app'); const files = [path.join('assets', 'icon.svg'), 'app.py']; if (answers.license) { files.push('LICENSE'); files.push('CODE_OF_CONDUCT.md'); files.push('CONTRIBUTING.md'); } for (let i = 0; i < files.length; i++) { const file = files[i]; try { await copyFileAsync(path.join(templatePath, file), path.join(appPath, file)); } catch (err) { Log(err); } } const gitIgnore = '/env.json\n/.homeybuild/\n'; await writeFileAsync(path.join(appPath, '.gitignore'), gitIgnore.toString()); await writeFileAsync( path.join(appPath, '.homeychangelog.json'), JSON.stringify( { [appJson.version]: { en: 'First version!', }, }, false, 2, ), ); Log.success(`App created in \`${appPath}\``); Log( `\nLearn more about Homey app development at: ${colors.underline('https://apps.developer.homey.app')}\n`, ); } skipFolderForOpenAI(file) { return ( file === 'locales' || file === '.venv' || file === PYTHON_PACKAGES_FOLDER || file === '.homeybuild' ); } static async addTypes({ appPath, findLinks }) { throw new Error('Installing homey-stubs locally needs to be done manually'); } static async addGitHubWorkflows({ appPath }) { throw new Error('Github workflows are not supported for Python yet.'); } async syncPythonVersion(pythonVersionPath) { let manifest; try { manifest = App.getComposeManifest({ appPath: this.path }); } catch (err) { manifest = App.getManifest({ appPath: this.path }); } HomeyLibApp.validatePythonVersion(manifest); const pythonVersion = manifest.pythonVersion; const previousPythonVersion = await fsPromises .readFile(pythonVersionPath, 'utf8') .catch(() => undefined); if (pythonVersion !== previousPythonVersion) { Log.success(`Python version changed to ${pythonVersion}`); await fse.ensureFile(pythonVersionPath); await fsPromises.writeFile(pythonVersionPath, pythonVersion); } } async syncPyproject(pyprojectPath) { let manifest; try { manifest = App.getComposeManifest({ appPath: this.path }); } catch (err) { manifest = App.getManifest({ appPath: this.path }); } const pyproject = { project: { name: manifest.id, version: manifest.version, dependencies: manifest.pythonPackages ?? [], }, }; await fse.ensureFile(pyprojectPath); await fsPromises.writeFile(pyprojectPath, TOML.stringify(pyproject)); } async installDependencies({ findLinks, dockerSocketPath }) { await this.uvCommand(['uv', 'sync', '--active', '--no-dev', '-q'], { findLinks, dockerSocketPath, }); Log.success('Installed dependencies'); await this.listDependencies(); } async addDependencies({ dependencies, findLinks, dockerSocketPath }) { // Write dependencies to app manifest let manifest; let manifestPath; try { manifest = App.getComposeManifest({ appPath: this.path }); manifestPath = path.join(this.path, '.homeycompose', 'app.json'); } catch (err) { manifest = App.getManifest({ appPath: this.path }); manifestPath = path.join(this.path, 'app.json'); } const oldDependencies = manifest.pythonPackages ?? []; // Add/update the given dependencies const versions = AppPython.parseDependencyVersions(oldDependencies); const newVersions = AppPython.parseDependencyVersions(dependencies); for (const [packageName, newVersion] of newVersions.entries()) { const oldVersion = versions.get(packageName); if (newVersion !== undefined && versions.has(packageName)) { Log(`Changing ${packageName} version from ${oldVersion} to ${newVersion}`); } versions.set(packageName, newVersion ?? oldVersion); } const newDependencies = []; for (const [packageName, version] of versions.entries()) { if (version !== undefined) { newDependencies.push(`${packageName}${version}`); } else { newDependencies.push(packageName); } } manifest.pythonPackages = newDependencies; await writeFileAsync(manifestPath, JSON.stringify(manifest, undefined, 2)); Log.info('Updated app manifest'); // Update venvs according to manifest await this.installDependencies({ findLinks, dockerSocketPath }).catch(async (error) => { manifest.pythonPackages = oldDependencies; await writeFileAsync(manifestPath, JSON.stringify(manifest, undefined, 2)).catch(() => { Log.info('Failed to restore app manifest'); }); Log.info('Restored app manifest'); throw error; }); } async removeDependencies({ dependencies, findLinks, dockerSocketPath }) { // Write dependencies to app manifest let manifest; let manifestPath; try { manifest = App.getComposeManifest({ appPath: this.path }); manifestPath = path.join(this.path, '.homeycompose', 'app.json'); } catch (err) { manifest = App.getManifest({ appPath: this.path }); manifestPath = path.join(this.path, 'app.json'); } const oldDependencies = manifest.pythonPackages ?? []; const versions = AppPython.parseDependencyVersions(oldDependencies); // Remove the given dependencies for (const packageName of dependencies) { versions.delete(packageName); } const newDependencies = []; for (const [packageName, version] of versions.entries()) { if (version !== undefined) { newDependencies.push(`${packageName}${version}`); } else { newDependencies.push(packageName); } } manifest.pythonPackages = newDependencies; await writeFileAsync(manifestPath, JSON.stringify(manifest, undefined, 2)); Log.info('Updated app manifest'); // Update venvs according to manifest await this.installDependencies({ findLinks, dockerSocketPath }).catch(async (error) => { manifest.pythonPackages = oldDependencies; await writeFileAsync(manifestPath, JSON.stringify(manifest, undefined, 2)).catch(() => { Log.info('Failed to restore app manifest'); }); Log.info('Restored app manifest'); throw error; }); } static parseDependencyVersions(dependencyStrings) { const versionMap = new Map(); for (const dependency of dependencyStrings) { const [packageName, version] = dependency.split(/((?:[<>]=?|=).*)/); versionMap.set(packageName, version); } return versionMap; } async listDependencies() { let manifest; try { manifest = App.getComposeManifest({ appPath: this.path }); } catch (err) { manifest = App.getManifest({ appPath: this.path }); } const dependencies = manifest.pythonPackages ?? []; Log(dependencies.join('\n')); } static getLocalPlatform() { switch (arch) { case 'x64': return 'amd64'; default: // Fall back to arm64 for unsupported architectures return 'arm64'; } } async uvCommand(command, { findLinks, dockerSocketPath } = {}) { const docker = await DockerHelper.ensureDocker({ dockerSocketPath }); const platforms = HomeyLibApp.REQUIRED_PYTHON_PLATFORMS; if (!process.env.HOMEY_APP_BUILDER_ID_PYTHON) { await DockerHelper.imagePullIfNeeded( 'ghcr.io/athombv/python-homey-app-builder-amd64:latest', 'amd64', ); await DockerHelper.imagePullIfNeeded( 'ghcr.io/athombv/python-homey-app-builder-arm64:latest', 'arm64', ); } const cacheBasePath = path.join(this.path, PYTHON_PACKAGES_FOLDER); // Generate .python-version const pythonVersionPath = path.join(cacheBasePath, '.python-version'); await this.syncPythonVersion(pythonVersionPath); for (const platform of platforms) { // Use a temporary directory to store package management files const packageManagementDir = await tmp.dir({ unsafeCleanup: true }); try { // Generate a completely new pyproject.toml const pyprojectPath = path.join(packageManagementDir.path, 'pyproject.toml'); await this.syncPyproject(pyprojectPath); await fsPromises.cp( pythonVersionPath, path.join(packageManagementDir.path, '.python-version'), ); const containerBinds = [`${packageManagementDir.path}:/project`]; const venvPath = path.join(cacheBasePath, platform, '.venv'); const uvCachePath = path.join(cacheBasePath, platform, 'uv_cache'); await fse.ensureDir(venvPath); await fse.ensureDir(uvCachePath); containerBinds.push(`${venvPath}:/.venv`, `${uvCachePath}:/uv_cache`); const dockerCommand = [...command]; if (findLinks !== undefined) { containerBinds.push(`${findLinks}:/find_links:ro`); dockerCommand.push('--find-links', '/find-links'); } const createOptions = { WorkingDir: '/project', HostConfig: { AutoRemove: true, Binds: containerBinds, }, User: process.getuid !== undefined ? `${process.getuid()}:${process.getgid()}` : undefined, platform, }; try { Log.success('Executing for', platform, 'architecture...'); await this.executeUvCommand( docker, BUILDER_IMAGES[platform], dockerCommand, createOptions, platform, ); } finally { // Remove .gitignore from python_packages venvs so they can be checked into version control const ignorePath = path.join(venvPath, '.gitignore'); await fse.remove(ignorePath).catch(() => { // ignore }); } } finally { await packageManagementDir.cleanup().catch(() => { // ignore }); } } } async executeUvCommand( docker, HOMEY_APP_BUILDER_ID, dockerCommand, createOptions, platform, retry = false, ) { const passThrough = new stream.PassThrough({ encoding: 'utf8' }); let runResolve; let runReject; const runPromise = new Promise((resolve, reject) => { runResolve = resolve; runReject = reject; }); // Use the docker.run callback to pass on error messages // Docker only puts its own errors in stderr, so we need to check the exit code and stdout as well const callback = (err, res) => { if (err !== null) { runReject(err); } else if (res.StatusCode !== 0) { const stderr = passThrough.read(); if (stderr !== null) { runReject(AppPython.cleanUvError(stderr)); } else { runReject(); } } else { const stdout = passThrough.read(); runResolve(stdout); } }; docker.run(HOMEY_APP_BUILDER_ID, dockerCommand, passThrough, createOptions, callback); let success = false; await runPromise .then(() => { success = true; }) .catch(async (error) => { // Check whether the error was caused by emulation issues or should just be thrown if (error instanceof Error) { throw error; } else if (error.endsWith('exec format error')) { if (retry) { throw new StacklessError('Failed to set up QEMU emulation for Docker.'); } // Emulation was not set up correctly await DockerHelper.fixDockerEmulationError(platform, docker); Log.success('Retrying command...'); retry = true; } else { throw new Error(error); } }); if (!success && retry) { await this.executeUvCommand( docker, HOMEY_APP_BUILDER_ID, dockerCommand, createOptions, platform, true, ); } } static cleanUvError(error) { let cleanError = error.replace(/^x/, ''); cleanError = cleanError.split('help:')[0]; cleanError = cleanError.trim(); return cleanError; } } module.exports = AppPython;