diff --git a/.vscode/settings.json b/.vscode/settings.json index b71b3c15..9c1ea756 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,7 @@ "editor.detectIndentation": false, "[typescript]": { "editor.codeActionsOnSave": { - "source.organizeImports": true + "source.organizeImports": "explicit" } }, "[javascript]": { @@ -22,5 +22,6 @@ "[markdown]": { "editor.tabSize": 4, "editor.insertSpaces": true - } + }, + "cSpell.words": ["alcalzone", "iobroker", "Pids"] } diff --git a/LICENSE b/LICENSE index 2a4937ef..fc6fdb24 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2025 UncleSamSwiss +Copyright (c) 2021-2026 UncleSamSwiss Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 42abb87e..5dd7dbba 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ The following options are available: `--admin ` Define which version of admin to be used (default: "latest"). +`--remote` Set up dev-server on a remote host using SSH. You will be prompted for connection details. + `--backupFile ` Provide an ioBroker backup file to restore in this dev-server. Use this option to populate the dev-server with data (and possibly other adapters). `--symlinks` Use symlinks instead of packing and installing the current adapter for a smoother dev experience. Requires JS-Controller 5+. @@ -325,16 +327,29 @@ When the adapter is ready, you will see a message like the following: ╰──────────────────────────────────────────────────╯ ``` -You can now attach the Visual Studio Code debugger to the given process ID: +You can now attach the Visual Studio Code debugger. -- Open the Command Palette (Ctrl-Shift-P) -- Choose "Debug: Attach to Node Process (legacy)" -- Select the right process, it usually looks like follows: +Create (or extend) a file called `.vscode/launch.json`: +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "attach", + "name": "Attach to remote", + "address": "127.0.0.1", + "port": 9229, + "outFiles": [] + } + ] +} ``` -node --inspect /node_modules/... -process id: 1234, debug port: 9229 -``` + +Note: It is important to set `"outFiles"` at least to an empty array, otherwise breakpoints will not work. + +Go to `Run and debug` (Ctrl-Shift-D), select `Attach to remote` and click on the green "play" button. Now you can set breakpoints (or they are hit if you set them before) and inspect your adapter while running. diff --git a/dist/DevServer.js b/dist/DevServer.js new file mode 100644 index 00000000..6961c932 --- /dev/null +++ b/dist/DevServer.js @@ -0,0 +1,365 @@ +#!/usr/bin/env node +import axios from 'axios'; +import chalk from 'chalk'; +import enquirer from 'enquirer'; +import { existsSync } from 'node:fs'; +import { mkdir, readdir, rename } from 'node:fs/promises'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gt } from 'semver'; +import yargs from 'yargs/yargs'; +import { Backup } from './commands/Backup.js'; +import { Debug } from './commands/Debug.js'; +import { Run } from './commands/Run.js'; +import { Setup } from './commands/Setup.js'; +import { SetupRemote } from './commands/SetupRemote.js'; +import { Update } from './commands/Update.js'; +import { Upload } from './commands/Upload.js'; +import { readJson } from './commands/utils.js'; +import { Watch } from './commands/Watch.js'; +import { WatchRemote } from './commands/WatchRemote.js'; +import { Logger } from './logger.js'; +const DEFAULT_TEMP_DIR_NAME = '.dev-server'; +const CORE_MODULE = 'iobroker.js-controller'; +const DEFAULT_ADMIN_PORT = 8081; +const DEFAULT_PROFILE_NAME = 'default'; +export class DevServer { + log; + rootPath; + adapterName; + tempPath; + profileName; + profilePath; + config; + constructor() { + const parser = yargs(process.argv.slice(2)); + void parser + .usage('Usage: $0 [options] [profile]\n or: $0 --help to see available options for a command') + .command(['setup [profile]', 's'], 'Set up dev-server in the current directory. This should always be called in the directory where the io-package.json file of your adapter is located.', { + adminPort: { + type: 'number', + default: DEFAULT_ADMIN_PORT, + alias: 'p', + description: 'TCP port on which ioBroker.admin will be available', + }, + jsController: { + type: 'string', + alias: 'j', + default: 'latest', + description: 'Define which version of js-controller to be used', + }, + admin: { + type: 'string', + alias: 'a', + default: 'latest', + description: 'Define which version of admin to be used', + }, + backupFile: { + type: 'string', + alias: 'b', + description: 'Provide an ioBroker backup file to restore in this dev-server', + }, + remote: { + type: 'boolean', + description: 'Install dev-server on a remote host and connect via SSH', + }, + force: { type: 'boolean', hidden: true }, + symlinks: { + type: 'boolean', + alias: 'l', + default: false, + description: 'Use symlinks instead of packing and installing the current adapter for a smoother dev experience. Requires JS-Controller 5+.', + }, + }, async (args) => await this.setup(args.adminPort, { ['iobroker.js-controller']: args.jsController, ['iobroker.admin']: args.admin }, args.backupFile, !!args.remote, !!args.force, args.symlinks)) + .command(['update [profile]', 'ud'], 'Update ioBroker and its dependencies to the latest versions', {}, async () => await this.update()) + .command(['run [profile]', 'r'], 'Run ioBroker dev-server, the adapter will not run, but you may test the Admin UI with hot-reload', { + noBrowserSync: { + type: 'boolean', + alias: 'b', + description: 'Do not use BrowserSync for hot-reload (serve static files instead)', + }, + }, async (args) => await this.run(!args.noBrowserSync)) + .command(['watch [profile]', 'w'], 'Run ioBroker dev-server and start the adapter in "watch" mode. The adapter will automatically restart when its source code changes. You may attach a debugger to the running adapter.', { + noStart: { + type: 'boolean', + alias: 'n', + description: 'Do not start the adapter itself, only watch for changes and sync them.', + }, + noInstall: { + type: 'boolean', + alias: 'x', + description: 'Do not build and install the adapter before starting.', + }, + doNotWatch: { + type: 'string', + alias: 'w', + description: 'Do not watch the given files or directories for changes (provide paths relative to the adapter base directory.', + }, + noBrowserSync: { + type: 'boolean', + alias: 'b', + description: 'Do not use BrowserSync for hot-reload (serve static files instead)', + }, + }, async (args) => await this.watch(!args.noStart, !!args.noInstall, args.doNotWatch, !args.noBrowserSync)) + .command(['debug [profile]', 'd'], 'Run ioBroker dev-server and start the adapter from ioBroker in "debug" mode. You may attach a debugger to the running adapter.', { + wait: { + type: 'boolean', + alias: 'w', + description: 'Start the adapter only once the debugger is attached.', + }, + noInstall: { + type: 'boolean', + alias: 'x', + description: 'Do not build and install the adapter before starting.', + }, + }, async (args) => await this.debug(!!args.wait, !!args.noInstall)) + .command(['upload [profile]', 'ul'], 'Upload the current version of your adapter to the ioBroker dev-server. This is only required if you changed something relevant in your io-package.json', {}, async () => await this.upload()) + .command(['backup [profile]', 'b'], 'Create an ioBroker backup to the given file.', {}, async (args) => await this.backup(args.filename)) + .command(['profile', 'p'], 'List all dev-server profiles that exist in the current directory.', {}, async () => await this.profile()) + .options({ + temp: { + type: 'string', + alias: 't', + default: DEFAULT_TEMP_DIR_NAME, + description: 'Temporary directory where the dev-server data will be located', + }, + root: { type: 'string', alias: 'r', hidden: true, default: '.' }, + verbose: { type: 'boolean', hidden: true, default: false }, + }) + .middleware(async (argv) => await this.setLogger(argv)) + .middleware(async () => await this.checkVersion()) + .middleware(async (argv) => await this.setDirectories(argv)) + .middleware(async () => await this.parseConfig()) + .wrap(Math.min(100, parser.terminalWidth())) + .showHelpOnFail(false) + .help().argv; + } + setLogger(argv) { + this.log = new Logger(argv.verbose ? 'silly' : 'debug'); + return Promise.resolve(); + } + async checkVersion() { + try { + const { name, version: localVersion } = await this.readMyPackageJson(); + const { data: { version: releaseVersion }, } = await axios.get(`https://registry.npmjs.org/${name}/latest`, { timeout: 1000 }); + if (gt(releaseVersion, localVersion)) { + this.log.debug(`Found update from ${localVersion} to ${releaseVersion}`); + const response = await enquirer.prompt({ + name: 'update', + type: 'confirm', + message: `Version ${releaseVersion} of ${name} is available.\nWould you like to exit and update?`, + initial: true, + }); + if (response.update) { + this.log.box(`Please update ${name} manually and restart your last command afterwards.\n` + + `If you installed ${name} globally, you can simply call:\n\nnpm install --global ${name}`); + return process.exit(0); + } + this.log.warn(`We strongly recommend to update ${name} as soon as possible.`); + } + } + catch { + // ignore + } + } + async readMyPackageJson() { + const dirname = path.dirname(fileURLToPath(import.meta.url)); + return readJson(path.join(dirname, '..', 'package.json')); + } + async setDirectories(argv) { + this.rootPath = path.resolve(argv.root); + this.tempPath = path.resolve(this.rootPath, argv.temp); + if (existsSync(path.join(this.tempPath, 'package.json'))) { + // we are still in the old directory structure (no profiles), let's move it + const intermediateDir = path.join(this.rootPath, `${DEFAULT_TEMP_DIR_NAME}-temp`); + const defaultProfileDir = path.join(this.tempPath, DEFAULT_PROFILE_NAME); + this.log.debug(`Moving temporary data from ${this.tempPath} to ${defaultProfileDir}`); + await rename(this.tempPath, intermediateDir); + await mkdir(this.tempPath); + await rename(intermediateDir, defaultProfileDir); + } + let profileName = argv.profile; + const profiles = await this.getProfiles(); + const profileNames = Object.keys(profiles); + if (profileName) { + if (!argv._.includes('setup') && !argv._.includes('s')) { + // ensure the profile exists + if (!profileNames.includes(profileName)) { + throw new Error(`Profile ${profileName} doesn't exist`); + } + } + } + else { + if (argv._.includes('profile') || argv._.includes('p')) { + // we don't care about the profile name + profileName = DEFAULT_PROFILE_NAME; + } + else { + if (profileNames.length === 0) { + profileName = DEFAULT_PROFILE_NAME; + this.log.debug(`Using default profile ${profileName}`); + } + else if (profileNames.length === 1) { + profileName = profileNames[0]; + this.log.debug(`Using profile ${profileName}`); + } + else { + this.log.box(chalk.yellow(`You didn't specify the profile name in the command line. ` + + `You may do so the next time by appending the profile name to your command.\nExample:\n` + + `> dev-server ${process.argv.slice(2).join(' ')} ${profileNames[profileNames.length - 1]} `)); + const response = await enquirer.prompt({ + name: 'profile', + type: 'select', + message: 'Please choose a profile', + choices: profileNames.map(p => ({ + name: p, + hint: chalk.gray(`(Admin Port: ${profiles[p]['dev-server'].adminPort})`), + })), + }); + profileName = response.profile; + } + } + } + if (!profileName.match(/^[a-z0-9_-]+$/i)) { + throw new Error(`Invalid profile name: "${profileName}", it may only contain a-z, 0-9, _ and -.`); + } + this.profileName = profileName; + this.log.debug(`Using profile name "${this.profileName}"`); + this.profilePath = path.join(this.tempPath, profileName); + this.adapterName = await this.findAdapterName(); + } + async parseConfig() { + let pkg; + try { + pkg = await readJson(path.join(this.profilePath, 'package.json')); + } + catch { + // not all commands need the config + return; + } + this.config = pkg['dev-server']; + } + async findAdapterName() { + try { + const ioPackage = await readJson(path.join(this.rootPath, 'io-package.json')); + const adapterName = ioPackage.common.name; + this.log.debug(`Using adapter name "${adapterName}"`); + return adapterName; + } + catch (error) { + this.log.warn(error); + this.log.error('You must run dev-server in the adapter root directory (where io-package.json resides).'); + return process.exit(-1); + } + } + ////////////////// Command Handlers ////////////////// + async setup(adminPort, dependencies, backupFile, remote, force, useSymlinks) { + let setup; + if (remote) { + setup = new SetupRemote(this, adminPort, dependencies, backupFile, force); + } + else { + setup = new Setup(this, adminPort, dependencies, backupFile, force, useSymlinks); + } + await setup.run(); + } + async update() { + this.checkSetup(); + const update = new Update(this); + await update.run(); + } + async run(useBrowserSync = true) { + this.checkSetup(); + const run = new Run(this, useBrowserSync); + await run.run(); + } + async watch(startAdapter, noInstall, doNotWatch, useBrowserSync = true) { + let doNotWatchArr = []; + if (typeof doNotWatch === 'string') { + doNotWatchArr.push(doNotWatch); + } + else if (Array.isArray(doNotWatch)) { + doNotWatchArr = doNotWatch; + } + this.checkSetup(); + let watch; + if (this.config?.remote) { + watch = new WatchRemote(this, startAdapter, noInstall, doNotWatchArr, useBrowserSync); + } + else { + watch = new Watch(this, startAdapter, noInstall, doNotWatchArr, useBrowserSync); + } + await watch.run(); + } + async debug(wait, noInstall) { + this.checkSetup(); + const debug = new Debug(this, wait, noInstall); + await debug.run(); + } + async upload() { + this.checkSetup(); + const upload = new Upload(this); + await upload.run(); + } + async backup(filename) { + this.checkSetup(); + const backup = new Backup(this, filename); + await backup.run(); + } + async profile() { + const profiles = await this.getProfiles(); + const table = Object.keys(profiles).map(name => { + const pkg = profiles[name]; + const infos = pkg['dev-server']; + const dependencies = pkg.dependencies; + return [ + name, + `http://127.0.0.1:${infos.adminPort}`, + infos.remote ? `${infos.remote.user}@${infos.remote.host}:${infos.remote.port}` : '(local)', + dependencies['iobroker.js-controller'], + dependencies['iobroker.admin'], + ]; + }); + table.unshift(['Profile Name', 'Admin URL', 'Remote Host', 'js-controller', 'admin'].map(h => chalk.bold(h))); + this.log.info(`The following profiles exist in ${this.tempPath}`); + this.log.table(table.filter(r => !!r)); + } + ////////////////// Command Helper Methods ////////////////// + async getProfiles() { + if (!existsSync(this.tempPath)) { + return {}; + } + const entries = await readdir(this.tempPath); + const pkgs = await Promise.all(entries.map(async (e) => { + try { + const pkg = await readJson(path.join(this.tempPath, e, 'package.json')); + const infos = pkg['dev-server']; + const dependencies = pkg.dependencies; + if (infos?.adminPort && dependencies) { + return [e, pkg]; + } + } + catch { + return undefined; + } + }, {})); + return pkgs.filter(p => !!p).reduce((old, [e, pkg]) => ({ ...old, [e]: pkg }), {}); + } + checkSetup() { + if (!this.isSetUp()) { + this.log.error(`dev-server is not set up in ${this.profilePath}.\nPlease use the command "setup" first to set up dev-server.`); + return process.exit(-1); + } + } + isSetUp() { + const jsControllerDir = path.join(this.profilePath, 'node_modules', CORE_MODULE); + if (existsSync(jsControllerDir)) { + return true; + } + if (this.config?.remote) { + // remote case (we didn't install js-controller locally) + return true; + } + return false; + } +} diff --git a/dist/commands/Backup.js b/dist/commands/Backup.js new file mode 100644 index 00000000..28da24c0 --- /dev/null +++ b/dist/commands/Backup.js @@ -0,0 +1,18 @@ +import path from 'node:path'; +import { CommandBase, IOBROKER_COMMAND } from './CommandBase.js'; +const BACKUP_POSTFIX = `_backupiobroker`; +export class Backup extends CommandBase { + filename; + constructor(owner, filename) { + super(owner); + this.filename = filename; + } + async doRun() { + let fullPath = path.resolve(this.filename); + if (!fullPath.endsWith(BACKUP_POSTFIX)) { + fullPath += BACKUP_POSTFIX; + } + this.log.notice(`Creating backup to ${fullPath}`); + await this.profileDir.execWithNewFile(fullPath, f => `${IOBROKER_COMMAND} backup "${f}"`); + } +} diff --git a/dist/commands/CommandBase.js b/dist/commands/CommandBase.js new file mode 100644 index 00000000..b0333018 --- /dev/null +++ b/dist/commands/CommandBase.js @@ -0,0 +1,136 @@ +import path from 'node:path'; +import { rimraf } from 'rimraf'; +import { LocalDirectory } from './LocalDirectory.js'; +import { RemoteConnection } from './RemoteConnection.js'; +import { getChildProcesses, readJson } from './utils.js'; +export const IOBROKER_CLI = 'node_modules/iobroker.js-controller/iobroker.js'; +export const IOBROKER_COMMAND = `node ${IOBROKER_CLI}`; +export const IOBROKER_CONTROLLER = 'node_modules/iobroker.js-controller/controller.js'; +export const HIDDEN_ADMIN_PORT_OFFSET = 12345; +export const HIDDEN_BROWSER_SYNC_PORT_OFFSET = 14345; +export const STATES_DB_PORT_OFFSET = 16345; +export const OBJECTS_DB_PORT_OFFSET = 18345; +export class CommandBase { + owner; + rootDir; + profileDir; + constructor(owner) { + this.owner = owner; + this.rootDir = new LocalDirectory(this.rootPath, this.log); + if (this.owner.config?.remote) { + this.profileDir = new RemoteConnection(this.owner.config.remote, this.log); + } + else { + this.profileDir = new LocalDirectory(this.profilePath, this.log); + } + } + get log() { + return this.owner.log; + } + get rootPath() { + return this.owner.rootPath; + } + get profilePath() { + return this.owner.profilePath; + } + get adapterName() { + return this.owner.adapterName; + } + get config() { + if (!this.owner.config) { + throw new Error('DevServer is not configured yet'); + } + return this.owner.config; + } + async run() { + await this.prepare(); + await this.doRun(); + await this.teardown(); + } + async prepare() { + if (this.profileDir instanceof RemoteConnection) { + await this.profileDir.connect(); + } + } + teardown() { + if (this.profileDir instanceof RemoteConnection) { + this.profileDir.close(); + } + return Promise.resolve(); + } + getPort(offset) { + return this.config.adminPort + offset; + } + isJSController() { + return this.adapterName === 'js-controller'; + } + readPackageJson() { + return this.rootDir.readJson('package.json'); + } + /** + * Read and parse the io-package.json file from the adapter directory + * + * @returns Promise resolving to the parsed io-package.json content + */ + readIoPackageJson() { + return this.rootDir.readJson('io-package.json'); + } + isTypeScriptMain(mainFile) { + return !!(mainFile && mainFile.endsWith('.ts')); + } + async installLocalAdapter(doInstall = true) { + this.log.notice(`Install local iobroker.${this.adapterName}`); + if (this.config.useSymlinks) { + // This is the expected relative path + const relativePath = path.relative(this.profilePath, this.rootPath); + // Check if it is already used in package.json + const tempPkg = await readJson(path.join(this.profilePath, 'package.json')); + const depPath = tempPkg.dependencies?.[`iobroker.${this.adapterName}`]; + // If not, install it + if (depPath !== relativePath) { + await this.profileDir.exec(`npm install "${relativePath}"`); + } + } + else { + const { stdout } = await this.rootDir.getExecOutput('npm pack'); + const filename = stdout.trim(); + this.log.info(`Packed to ${filename}`); + const fullPath = path.join(this.rootPath, filename); + if (doInstall) { + await this.profileDir.execWithExistingFile(fullPath, f => `npm install "${f}"`); + await rimraf(fullPath); + } + else { + await this.profileDir.execWithExistingFile(fullPath, f => `ls -la "${f}"`); + } + } + } + async buildLocalAdapter() { + const pkg = await this.readPackageJson(); + if (pkg.scripts?.build) { + this.log.notice(`Build iobroker.${this.adapterName}`); + await this.rootDir.exec('npm run build'); + } + } + async uploadAdapter(name) { + this.log.notice(`Upload iobroker.${name}`); + await this.profileDir.exec(`${IOBROKER_COMMAND} upload ${name}`); + } + async exit(exitCode, signal = 'SIGINT') { + await this.rootDir.exitChildProcesses(signal); + await this.profileDir.exitChildProcesses(signal); + process.exit(exitCode); + } + async waitForNodeChildProcess(parentPid) { + const start = new Date().getTime(); + while (start + 2000 > new Date().getTime()) { + const processes = await getChildProcesses(parentPid); + const child = processes.find(p => p.COMMAND.match(/node/i)); + if (child) { + return parseInt(child.PID); + } + } + this.log.debug(`No node child process of ${parentPid} found, assuming parent process was reused.`); + return parentPid; + } +} diff --git a/dist/commands/Debug.js b/dist/commands/Debug.js new file mode 100644 index 00000000..6d59268d --- /dev/null +++ b/dist/commands/Debug.js @@ -0,0 +1,105 @@ +import chalk from 'chalk'; +import path from 'node:path'; +import { IOBROKER_CLI, IOBROKER_CONTROLLER } from './CommandBase.js'; +import { RemoteConnection } from './RemoteConnection.js'; +import { ADAPTER_DEBUGGER_PORT, RunCommandBase } from './RunCommandBase.js'; +export class Debug extends RunCommandBase { + wait; + noInstall; + constructor(owner, wait, noInstall) { + super(owner); + this.wait = wait; + this.noInstall = noInstall; + } + async prepare() { + await super.prepare(); + if (this.profileDir instanceof RemoteConnection) { + await this.profileDir.tunnelPort(ADAPTER_DEBUGGER_PORT); + } + } + async doRun() { + if (!this.noInstall) { + await this.buildLocalAdapter(); + await this.installLocalAdapter(); + } + await this.copySourcemaps(); + if (this.isJSController()) { + await this.startJsControllerDebug(); + await this.startServer(); + } + else { + await this.startJsController(); + await this.startServer(); + await this.startAdapterDebug(); + } + } + async copySourcemaps() { + const outDir = path.join('node_modules', `iobroker.${this.adapterName}`); + this.log.notice(`Creating or patching sourcemaps in ${outDir}`); + const sourcemaps = await this.findFiles('map', true); + if (sourcemaps.length === 0) { + this.log.debug(`Couldn't find any sourcemaps in ${this.rootPath},\nwill try to reverse map .js files`); + // search all .js files that exist in the node module in the temp directory as well as in the root directory and + // create sourcemap files for each of them + const jsFiles = await this.findFiles('js', true); + await Promise.all(jsFiles.map(async (js) => { + const src = path.join(this.rootPath, js); + const dest = path.join(outDir, js); + await this.addSourcemap(src, dest, false); + })); + return; + } + // copy all *.map files to the node module in the temp directory and + // change their sourceRoot so they can be found in the development directory + await Promise.all(sourcemaps.map(async (sourcemap) => { + const src = path.join(this.rootPath, sourcemap); + const dest = path.join(outDir, sourcemap); + await this.patchSourcemap(src, dest); + })); + } + async startJsControllerDebug() { + this.log.notice(`Starting debugger for ${this.adapterName}`); + const nodeArgs = ['--preserve-symlinks', '--preserve-symlinks-main', IOBROKER_CONTROLLER]; + if (this.wait) { + nodeArgs.unshift('--inspect-brk'); + } + else { + nodeArgs.unshift('--inspect'); + } + const pid = await this.profileDir.spawn('node', nodeArgs, code => { + console.error(chalk.yellow(`ioBroker controller exited with code ${code}`)); + return this.exit(-1); + }); + await this.waitForJsController(); + this.log.box(`Debugger is now ${this.wait ? 'waiting' : 'available'} on process id ${pid}`); + } + async startAdapterDebug() { + this.log.notice(`Starting ioBroker adapter debugger for ${this.adapterName}.0`); + const args = [ + '--preserve-symlinks', + '--preserve-symlinks-main', + IOBROKER_CLI, + 'debug', + `${this.adapterName}.0`, + ]; + if (this.config.remote) { + args.unshift(`--inspect=127.0.0.1:${ADAPTER_DEBUGGER_PORT}`); + } + if (this.wait) { + args.push('--wait'); + } + const pid = await this.profileDir.spawn('node', args, code => { + console.error(chalk.yellow(`Adapter debugging exited with code ${code}`)); + return this.exit(-1); + }); + let debugTarget; + if (pid) { + const debugPid = await this.waitForNodeChildProcess(pid); + debugTarget = `process id ${debugPid}`; + } + else { + debugTarget = `port 127.0.0.1:${ADAPTER_DEBUGGER_PORT}`; + } + this.log.box(`Debugger is now ${this.wait ? 'waiting' : 'available'} on ${debugTarget}`); + } +} diff --git a/dist/commands/IEnvironment.js b/dist/commands/IEnvironment.js new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/dist/commands/IEnvironment.js @@ -0,0 +1 @@ +export {}; diff --git a/dist/commands/LocalDirectory.js b/dist/commands/LocalDirectory.js new file mode 100644 index 00000000..682cf066 --- /dev/null +++ b/dist/commands/LocalDirectory.js @@ -0,0 +1,157 @@ +import * as cp from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { copyFile, readFile, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { delay, getChildProcesses, readJson, writeJson } from './utils.js'; +export class LocalDirectory { + directory; + log; + childProcesses = []; + constructor(directory, log) { + this.directory = directory; + this.log = log; + } + readFile(relPath) { + return readFile(path.join(this.directory, relPath), { encoding: 'utf-8' }); + } + writeFile(relPath, data) { + return writeFile(path.join(this.directory, relPath), data, { encoding: 'utf-8' }); + } + readJson(relPath) { + return readJson(path.join(this.directory, relPath)); + } + async writeJson(relPath, data) { + return writeJson(path.join(this.directory, relPath), data); + } + async copyFileTo(src, dest) { + await copyFile(src, path.join(this.directory, dest)); + } + exists(relPath) { + return Promise.resolve(existsSync(path.join(this.directory, relPath))); + } + async unlink(relPath) { + const fullPath = path.join(this.directory, relPath); + await unlink(fullPath); + } + exec(command) { + this.log.debug(`${this.directory}> ${command}`); + cp.execSync(command, { cwd: this.directory, stdio: 'inherit' }); + return Promise.resolve(); + } + async execWithExistingFile(fullPath, commandBuilder) { + await this.exec(commandBuilder(fullPath)); + } + async execWithNewFile(fullPath, commandBuilder) { + await this.exec(commandBuilder(fullPath)); + } + getExecOutput(command) { + this.log.debug(`${this.directory}> ${command}`); + return new Promise((resolve, reject) => { + this.childProcesses.push(cp.exec(command, { cwd: this.directory, encoding: 'ascii' }, (err, stdout, stderr) => { + if (err) { + reject(err); + } + else { + resolve({ stdout, stderr }); + } + })); + }); + } + async spawn(command, args, onExit) { + const proc = await this.spawnProcess(command, args); + proc.on('exit', async (code) => { + await onExit(code ?? -1); + }); + return proc.pid ?? null; + } + async spawnAndAwaitOutput(command, args, awaitMsg, options) { + const proc = await this.spawnProcess(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); + return new Promise((resolve, reject) => { + const handleStream = (isStderr) => (data) => { + let str = data.toString('utf-8'); + // eslint-disable-next-line no-control-regex + str = str.replace(/\x1Bc/, ''); // filter the "clear screen" ANSI code (used by tsc) + if (str) { + str = str.trimEnd(); + if (isStderr) { + console.error(str); + } + else { + console.log(str); + } + } + if (typeof awaitMsg === 'string') { + if (str.includes(awaitMsg)) { + resolve(proc); + } + } + else { + if (awaitMsg.test(str)) { + resolve(proc); + } + } + }; + proc.stdout?.on('data', handleStream(false)); + proc.stderr?.on('data', handleStream(true)); + proc.on('exit', code => reject(new Error(`Exited with ${code}`))); + process.on('SIGINT', () => { + proc.kill('SIGINT'); + reject(new Error('SIGINT')); + }); + }); + } + spawnProcess(command, args, options) { + return new Promise((resolve, reject) => { + let processSpawned = false; + this.log.debug(`${this.directory}> ${command} ${args.join(' ')}`); + const proc = cp.spawn(command, args, { + stdio: ['ignore', 'inherit', 'inherit'], + cwd: this.directory, + ...options, + }); + this.childProcesses.push(proc); + let alive = true; + proc.on('spawn', () => { + processSpawned = true; + resolve(proc); + }); + proc.on('error', err => { + this.log.error(`Could not spawn ${command}: ${err}`); + if (!processSpawned) { + reject(err); + } + }); + proc.on('exit', () => (alive = false)); + process.on('exit', () => alive && proc.kill('SIGINT')); + }); + } + async exitChildProcesses(signal = 'SIGINT') { + const childPids = this.childProcesses.map(p => p.pid).filter(p => !!p); + const tryKill = (pid, signal) => { + try { + process.kill(pid, signal); + } + catch { + // ignore + } + }; + try { + const children = await Promise.all(childPids.map(pid => getChildProcesses(pid))); + children.forEach(ch => ch.forEach(c => tryKill(parseInt(c.PID), signal))); + } + catch (error) { + this.log.error(`Couldn't kill grand-child processes: ${error}`); + } + if (childPids.length) { + childPids.forEach(pid => tryKill(pid, signal)); + if (signal !== 'SIGKILL') { + // first try SIGINT and give it 5s to exit itself before killing the processes left + await delay(5000); + return this.exitChildProcesses('SIGKILL'); + } + } + } + sendSigIntToChildProcesses() { + this.childProcesses.forEach(p => p.kill('SIGINT')); + } +} diff --git a/dist/commands/RemoteConnection.js b/dist/commands/RemoteConnection.js new file mode 100644 index 00000000..157357f0 --- /dev/null +++ b/dist/commands/RemoteConnection.js @@ -0,0 +1,358 @@ +import enquirer from 'enquirer'; +import { readFileSync } from 'node:fs'; +import { createServer } from 'node:net'; +import path from 'node:path'; +import { Client as SSHClient } from 'ssh2'; +import { exec as ssh2ExecAsync } from 'ssh2-exec/promises'; +import { delay } from './utils.js'; +export class RemoteConnection { + config; + log; + client = new SSHClient(); + connectState = 'disconnected'; + childProcesses = []; + tunnelServers = []; + connectSftp; + homeDir; + constructor(config, log) { + this.config = config; + this.log = log; + } + async connect() { + if (this.connectState !== 'disconnected') { + return; + } + this.log.notice(`Connecting to ${this.config.user}@${this.config.host}...`); + this.connectState = 'connecting'; + await new Promise((resolve, reject) => { + this.client.once('ready', () => { + this.connectState = 'connected'; + resolve(); + }); + this.client.once('error', err => { + this.log.error(`SSH connection error: ${err.message}`); + this.connectState = 'disconnected'; + reject(err); + }); + const connectConfig = { + host: this.config.host, + port: this.config.port, + username: this.config.user, + }; + if (this.config.privateKeyPath) { + connectConfig.privateKey = readFileSync(this.config.privateKeyPath); + } + else { + connectConfig.tryKeyboard = true; + this.client.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => { + this.log.notice(instructions); + async function askPassword() { + const result = []; + for (const p of prompts) { + const answer = await enquirer.prompt({ + name: 'password', + type: p.echo ? 'text' : 'password', + message: p.prompt, + }); + result.push(answer.password); + } + return result; + } + askPassword() + .then(finish) + .catch(err => this.log.error(`Error getting password: ${err}`)); + }); + } + this.client.connect(connectConfig); + }); + this.log.debug('Remote SSH connection established'); + process.on('SIGINT', () => void this.exitChildProcesses('SIGINT').catch(e => this.log.silly(`Couldn't exit child processes: ${e.message}`))); + } + close() { + if (this.connectState !== 'connected') { + return; + } + this.log.debug('Closing tunnels...'); + for (const server of this.tunnelServers) { + server.close(); + } + this.tunnelServers.length = 0; + this.log.debug('Closing remote SSH connection'); + this.connectState = 'disconnected'; + this.client.end(); + } + async readFile(relPath) { + const remotePath = await this.getFullRemotePath(relPath); + const sftp = await this.getSftp(); + const buffer = await sftp.readFile(remotePath); + return buffer.toString(); + } + async writeFile(relPath, data) { + const remotePath = await this.getFullRemotePath(relPath); + const sftp = await this.getSftp(); + await sftp.writeFile(remotePath, data); + } + async readJson(relPath) { + const content = await this.readFile(relPath); + return JSON.parse(content); + } + async writeJson(relPath, data) { + const content = JSON.stringify(data, null, 2); + return this.writeFile(relPath, content); + } + async copyFileTo(src, dest) { + await this.upload(src, dest); + } + async exists(relPath) { + const remotePath = await this.getFullRemotePath(relPath); + const sftp = await this.getSftp(); + return sftp.exists(remotePath); + } + async unlink(relPath) { + const remotePath = await this.getFullRemotePath(relPath); + const sftp = await this.getSftp(); + await sftp.unlink(remotePath); + } + async spawn(command, args, onExit) { + const basePath = this.getBasePath(); + this.log.debug(`${this.config.user}@${this.config.host}:${basePath}> ${command}`); + command = this.asBashCommand(`cd ${basePath} ; echo "PID=>$$<" ; exec ${command} ${args.map(a => `"${a}"`).join(' ')}`); + return new Promise((resolve, reject) => { + this.client.exec(command, { pty: true }, (err, stream) => { + if (err) { + return reject(err); + } + resolve(null); + stream.once('data', (data) => { + const match = data.toString().match(/PID=>(\d+) { + onExit(code ?? 1)?.catch((e) => this.log.error(`Error in onExit handler: ${e.message}`)); + }); + stream.pipe(process.stdout, { end: false }); + stream.stderr.pipe(process.stderr, { end: false }); + }); + }); + } + async exec(command) { + const basePath = this.getBasePath(); + this.log.debug(`${this.config.user}@${this.config.host}:${basePath}> ${command}`); + command = this.asBashCommand(`cd ${basePath} ; ${command}`); + return new Promise((resolve, reject) => { + this.client.exec(command, { pty: true }, (err, stream) => { + if (err) { + return reject(err); + } + stream.on('close', (code, signal) => { + if (code === 0) { + resolve(); + } + else { + reject(new Error(`Command failed with code ${code} (${signal})`)); + } + }); + stream.pipe(process.stdout, { end: false }); + stream.stderr.pipe(process.stderr, { end: false }); + }); + }); + } + async execWithExistingFile(fullPath, commandBuilder) { + const filename = path.basename(fullPath); + const remotePath = await this.upload(fullPath, filename); + await this.exec(commandBuilder(remotePath)); + } + async execWithNewFile(localPath, commandBuilder) { + const filename = path.basename(localPath); + const remotePath = await this.getFullRemotePath(filename); + await this.exec(commandBuilder(remotePath)); + const sftp = await this.getSftp(); + await sftp.get(remotePath, localPath); + await this.exec(`rm -f "${remotePath}"`); + } + async getExecOutput(command) { + this.log.debug(`${this.config.user}@${this.config.host}> ${command}`); + command = this.asBashCommand(command); + const result = await ssh2ExecAsync({ + ssh: this.client, + command, + end: false, + }); + return result.stdout; + } + asBashCommand(command) { + command = `/usr/bin/bash -lic '${command.replace(/'/g, "'\\''")}'`; + this.log.silly(`Remote command: ${command}`); + return command; + } + async exitChildProcesses(signal) { + if (signal === 'SIGKILL') { + this.close(); + } + else if (this.childProcesses.length > 0) { + const pids = [...this.childProcesses]; + this.childProcesses.length = 0; + for (const pid of pids) { + try { + await this.getExecOutput(`kill -s ${signal} ${pid}`); + } + catch (err) { + this.log.silly(`Failed to send ${signal} to remote process ${pid}: ${err}`); + } + } + // first try SIGINT and give it 5s to exit itself before killing the processes left + await delay(5000); + await this.exitChildProcesses('SIGKILL'); + } + } + sendSigIntToChildProcesses() { + // this method is only used locally when there is no TTY + this.close(); + } + async tunnelPort(port) { + this.log.notice(`Preparing tunnel for port ${port}...`); + const server = createServer(sock => { + sock.pause(); + this.log.silly(`Client connected to port ${port}, opening tunnel...`); + this.client.forwardOut('127.0.0.1', port, '127.0.0.1', port, (err, stream) => { + if (err) { + this.log.silly(`forwardOut for port ${port} failed: ${err.message}`); + sock.destroy(); + return; + } + this.log.silly(`Tunnel for port ${port} established (${sock.remoteAddress}:${sock.remotePort}).`); + sock.pipe(stream); + stream.pipe(sock); + sock.resume(); + }); + }); + this.tunnelServers.push(server); + return new Promise((resolve, reject) => { + server.on('error', err => { + this.log.error(`Failed to create local tunnel server: ${err.message}`); + reject(err); + }); + server.on('listening', () => { + resolve(); + }); + server.listen(port, '127.0.0.1'); + }); + } + async upload(localPath, relPath) { + const remotePath = await this.getFullRemotePath(relPath); + const sftp = await this.getSftp(); + await sftp.put(localPath, remotePath); + return remotePath; + } + async getFullRemotePath(relPath) { + const homeDir = await this.getHomeDir(); + return `${this.getBasePath(homeDir)}/${relPath}`; + } + getBasePath(home = '~') { + return `${home}/.dev-server/${this.config.id}`; + } + getSftp() { + if (!this.connectSftp) { + this.connectSftp = new Promise((resolve, reject) => { + this.client.sftp((err, sftp) => { + if (err) { + return reject(err); + } + resolve(new SftpConnection(sftp, this.log)); + }); + }); + } + return this.connectSftp; + } + async getHomeDir() { + if (!this.homeDir) { + this.homeDir = (await this.getExecOutput('echo $HOME')).trim(); + } + return this.homeDir; + } +} +class SftpConnection { + sftp; + log; + currentOperation; + constructor(sftp, log) { + this.sftp = sftp; + this.log = log; + } + get(remotePath, localPath) { + return this.run((resolve, reject) => { + this.log.notice(`Transferring ${remotePath} from remote host...`); + this.log.silly(`${remotePath} -> ${localPath}`); + this.sftp.fastGet(remotePath, localPath, {}, putErr => { + if (putErr) { + return reject(putErr); + } + resolve(); + }); + }); + } + put(localPath, remotePath) { + return this.run((resolve, reject) => { + this.log.notice(`Transferring ${localPath} to remote host...`); + this.log.silly(`${localPath} -> ${remotePath}`); + this.sftp.fastPut(localPath, remotePath, {}, putErr => { + if (putErr) { + return reject(putErr); + } + resolve(); + }); + }); + } + readFile(remotePath) { + return this.run((resolve, reject) => { + this.log.debug(`Reading ${remotePath} from remote host...`); + this.sftp.readFile(remotePath, { encoding: 'utf8' }, (err, data) => { + if (err) { + return reject(err); + } + resolve(data); + }); + }); + } + writeFile(remotePath, data) { + return this.run((resolve, reject) => { + this.log.debug(`Writing ${remotePath} to remote host...`); + this.sftp.writeFile(remotePath, data, { encoding: 'utf8' }, err => { + if (err) { + return reject(err); + } + resolve(); + }); + }); + } + exists(remotePath) { + return this.run(resolve => { + this.log.silly(`Checking existence of remote file ${remotePath}...`); + this.sftp.exists(remotePath, exists => { + this.log.silly(`Remote file ${remotePath} exists: ${exists}`); + resolve(exists); + }); + }); + } + async unlink(remotePath) { + return this.run((resolve, reject) => { + this.log.notice(`Deleting remote file ${remotePath}...`); + this.sftp.unlink(remotePath, err => { + if (err) { + return reject(err); + } + resolve(); + }); + }); + } + async run(executor) { + await this.currentOperation; + const operation = new Promise(executor); + this.currentOperation = operation; + return operation; + } +} diff --git a/dist/commands/Run.js b/dist/commands/Run.js new file mode 100644 index 00000000..89d09814 --- /dev/null +++ b/dist/commands/Run.js @@ -0,0 +1,12 @@ +import { RunCommandBase } from './RunCommandBase.js'; +export class Run extends RunCommandBase { + useBrowserSync; + constructor(owner, useBrowserSync) { + super(owner); + this.useBrowserSync = useBrowserSync; + } + async doRun() { + await this.startJsController(); + await this.startServer(this.useBrowserSync); + } +} diff --git a/dist/commands/RunCommandBase.js b/dist/commands/RunCommandBase.js new file mode 100644 index 00000000..982f75cc --- /dev/null +++ b/dist/commands/RunCommandBase.js @@ -0,0 +1,585 @@ +import { tokenizer } from 'acorn'; +import axios from 'axios'; +import browserSync from 'browser-sync'; +import chalk from 'chalk'; +import express from 'express'; +import fg from 'fast-glob'; +import { legacyCreateProxyMiddleware as createProxyMiddleware } from 'http-proxy-middleware'; +import EventEmitter from 'node:events'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { SourceMapGenerator } from 'source-map'; +import WebSocket from 'ws'; +import { injectCode } from '../jsonConfig.js'; +import { CommandBase, HIDDEN_ADMIN_PORT_OFFSET, HIDDEN_BROWSER_SYNC_PORT_OFFSET, IOBROKER_CONTROLLER, OBJECTS_DB_PORT_OFFSET, STATES_DB_PORT_OFFSET, } from './CommandBase.js'; +import { RemoteConnection } from './RemoteConnection.js'; +import { checkPort, delay, readJson } from './utils.js'; +const CONTROLLER_DEBUGGER_PORT = 9228; +export const ADAPTER_DEBUGGER_PORT = 9229; +export class RunCommandBase extends CommandBase { + websocket; + socketEvents = new EventEmitter(); + async prepare() { + await super.prepare(); + if (this.profileDir instanceof RemoteConnection) { + await this.profileDir.tunnelPort(this.getPort(HIDDEN_ADMIN_PORT_OFFSET)); + await this.profileDir.tunnelPort(this.getPort(STATES_DB_PORT_OFFSET)); + await this.profileDir.tunnelPort(this.getPort(OBJECTS_DB_PORT_OFFSET)); + await this.profileDir.tunnelPort(CONTROLLER_DEBUGGER_PORT); + } + } + teardown() { + // do not close remote connection here (do it in exit() instead) + return Promise.resolve(); + } + async exit(exitCode, signal = 'SIGINT') { + return super.exit(exitCode, signal); + } + async startJsController() { + await this.profileDir.spawn('node', [ + `--inspect=127.0.0.1:${CONTROLLER_DEBUGGER_PORT}`, + '--preserve-symlinks', + '--preserve-symlinks-main', + IOBROKER_CONTROLLER, + ], async (code) => { + console.error(chalk.yellow(`ioBroker controller exited with code ${code}`)); + return this.exit(-1, 'SIGKILL'); + }); + this.log.notice('Waiting for js-controller to start...'); + await this.waitForJsController(); + } + async waitForJsController() { + if (!(await this.waitForPort(OBJECTS_DB_PORT_OFFSET, 'objects DB')) || + !(await this.waitForPort(STATES_DB_PORT_OFFSET, 'states DB'))) { + throw new Error(`Couldn't start js-controller`); + } + } + async waitForPort(offset, name, timeout = 30) { + const port = this.getPort(offset); + this.log.debug(`Waiting for port ${port} (${name}) to be available...`); + let tries = 0; + while (true) { + try { + await checkPort(port); + this.log.debug(`Port ${port} (${name}) is available...`); + return true; + } + catch { + if (tries++ > timeout) { + this.log.error(`Port ${port} (${name}) is not available after ${timeout} seconds.`); + return false; + } + await delay(1000); + } + } + } + async startServer(useBrowserSync = true) { + await this.waitForPort(HIDDEN_ADMIN_PORT_OFFSET, 'admin', 90); + const app = express(); + const hiddenAdminPort = this.getPort(HIDDEN_ADMIN_PORT_OFFSET); + if (this.isJSController()) { + // simply forward admin as-is + app.use(createProxyMiddleware({ + target: `http://127.0.0.1:${hiddenAdminPort}`, + ws: true, + })); + } + else { + // Determine what UI capabilities this adapter needs + const uiCapabilities = await this.getAdapterUiCapabilities(); + if (uiCapabilities.configType === 'json' && uiCapabilities.tabType !== 'none') { + // Adapter uses jsonConfig AND has tabs - support both simultaneously + await this.createCombinedConfigProxy(app, uiCapabilities, useBrowserSync); + } + else if (uiCapabilities.configType === 'json') { + // JSON config only + await this.createJsonConfigProxy(app, useBrowserSync); + } + else { + // HTML config or tabs only (or no config) + await this.createHtmlConfigProxy(app, useBrowserSync); + } + } + // start express + this.log.notice(`Starting web server on port ${this.config.adminPort}`); + const server = app.listen(this.config.adminPort, '127.0.0.1'); + let exiting = false; + process.on('SIGINT', () => { + this.log.notice('dev-server is exiting...'); + exiting = true; + server.close(); + // do not kill this process when receiving SIGINT, but let all child processes exit first + // but send the signal to all child processes when not in a tty environment + if (!process.stdin.isTTY) { + this.log.silly('Sending SIGINT to all child processes...'); + this.rootDir.sendSigIntToChildProcesses(); + this.profileDir.sendSigIntToChildProcesses(); + } + }); + await new Promise((resolve, reject) => { + server.on('listening', resolve); + server.on('error', reject); + server.on('close', reject); + }); + if (!this.isJSController()) { + const connectWebSocketClient = () => { + if (exiting) { + return; + } + // TODO: replace this with @iobroker/socket-client + this.websocket = new WebSocket(`ws://127.0.0.1:${hiddenAdminPort}/?sid=${Date.now()}&name=admin`); + this.websocket.on('open', () => this.log.silly('WebSocket open')); + this.websocket.on('close', () => { + this.log.silly('WebSocket closed'); + this.websocket = undefined; + setTimeout(connectWebSocketClient, 1000); + }); + this.websocket.on('error', error => this.log.silly(`WebSocket error: ${error}`)); + this.websocket.on('message', msg => { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const msgString = msg?.toString(); + if (typeof msgString === 'string') { + try { + const data = JSON.parse(msgString); + if (!Array.isArray(data) || data.length === 0) { + return; + } + switch (data[0]) { + case 0: + if (data.length > 3) { + this.socketEvents.emit(data[2], data[3]); + } + break; + case 1: + // ping received, send pong (keep-alive) + this.websocket?.send('[2]'); + break; + } + } + catch (error) { + this.log.error(`Couldn't handle WebSocket message: ${error}`); + } + } + }); + }; + connectWebSocketClient(); + } + this.log.box(`Admin is now reachable under http://127.0.0.1:${this.config.adminPort}/`); + } + /** + * Detect adapter UI capabilities by reading io-package.json adminUi configuration + * + * This method determines how the adapter's configuration and tab UI should be handled + * by checking the adminUi field in io-package.json, which is the official ioBroker schema. + * It also checks for the presence of jsonConfig files to support legacy adapters. + * + * The detection logic replicates what the admin interface does to ensure dev-server + * behavior matches what users will see in production. + * + * @returns Promise resolving to an object containing: + * - configType: 'json' (jsonConfig), 'html' (HTML/React config), or 'none' + * - tabType: 'json' (jsonTab), 'html' (HTML/React tab), or 'none' + */ + async getAdapterUiCapabilities() { + let configType = 'none'; + let tabType = 'none'; + // Check for jsonConfig files first + if (this.getJsonConfigPath()) { + configType = 'json'; + } + if (!this.isJSController()) { + // Check io-package.json adminUi field (replicate what admin does) + try { + const ioPackage = await this.readIoPackageJson(); + if (ioPackage?.common?.adminUi) { + const adminUi = ioPackage.common.adminUi; + this.log.debug(`Found adminUi configuration in io-package.json: ${JSON.stringify(adminUi)}`); + // Set config type based on adminUi.config + if (adminUi.config === 'json') { + configType = 'json'; + } + else if (adminUi.config === 'html' || adminUi.config === 'materialize') { + configType = 'html'; + } + // Set tab type based on adminUi.tab + if (adminUi.tab === 'json') { + tabType = 'json'; + } + else if (adminUi.tab === 'html' || adminUi.tab === 'materialize') { + tabType = 'html'; + } + } + } + catch (error) { + this.log.debug(`Failed to read io-package.json adminUi: ${error}`); + } + } + this.log.debug(`UI capabilities: configType=${configType}, tabType=${tabType}`); + return { + configType, + tabType, + }; + } + getJsonConfigPath() { + const jsonConfigPath = path.resolve(this.rootPath, 'admin/jsonConfig.json'); + if (existsSync(jsonConfigPath)) { + return jsonConfigPath; + } + if (existsSync(`${jsonConfigPath}5`)) { + return `${jsonConfigPath}5`; + } + return ''; + } + /** + * Create a combined config proxy that supports adapters using both jsonConfig and tabs + * + * This method merges the functionality of createJsonConfigProxy and createHtmlConfigProxy + * to support adapters that need both configuration UI types simultaneously. It handles: + * - React build watching for HTML-based config or tabs + * - JSON config file watching with WebSocket hot-reload + * - JSON tab file watching with WebSocket hot-reload + * - HTML tab file watching with BrowserSync automatic reload + * - Appropriate proxy routing based on the UI types present + * + * Used when an adapter has jsonConfig AND also has custom tabs (either HTML or JSON-based). + * For adapters with only one UI type, use createJsonConfigProxy or createHtmlConfigProxy instead. + * + * @param app Express application instance + * @param uiCapabilities Object containing configType and tabType detected from io-package.json + * @param uiCapabilities.configType 'json' | 'html' | 'none' - type of configuration UI + * @param uiCapabilities.tabType 'json' | 'html' | 'none' - type of tab UI + * @param useBrowserSync Whether to use BrowserSync for hot-reload (default: true) + */ + async createCombinedConfigProxy(app, uiCapabilities, useBrowserSync = true) { + // This method combines the functionality of createJsonConfigProxy and createHtmlConfigProxy + // to support adapters that use jsonConfig and tabs simultaneously + const pathRewrite = {}; + const browserSyncPort = this.getPort(HIDDEN_BROWSER_SYNC_PORT_OFFSET); + const adminUrl = `http://127.0.0.1:${this.getPort(HIDDEN_ADMIN_PORT_OFFSET)}`; + let hasReact = false; + let bs = null; + if (useBrowserSync) { + // Setup React build watching if needed (for HTML config or HTML tabs) + if (uiCapabilities.configType === 'html' || uiCapabilities.tabType === 'html') { + hasReact = await this.setupReactWatch(pathRewrite); + } + // Start browser-sync + bs = this.startBrowserSync(browserSyncPort, hasReact); + } + // Handle jsonConfig file watching if present + if (uiCapabilities.configType === 'json' && useBrowserSync && bs) { + const jsonConfigFile = this.getJsonConfigPath(); + this.setupJsonFileWatch(bs, jsonConfigFile, path.basename(jsonConfigFile)); + // "proxy" for the main page which injects our script + app.get('/', async (_req, res) => { + const { data } = await axios.get(adminUrl); + res.send(injectCode(data, this.adapterName, path.basename(jsonConfigFile))); + }); + } + // Handle tab file watching if present + if (uiCapabilities.tabType !== 'none' && useBrowserSync && bs) { + if (uiCapabilities.tabType === 'json') { + // Watch JSON tab files + const jsonTabPath = path.resolve(this.rootPath, 'admin/jsonTab.json'); + const jsonTab5Path = path.resolve(this.rootPath, 'admin/jsonTab.json5'); + this.setupJsonFileWatch(bs, jsonTabPath, 'jsonTab.json'); + this.setupJsonFileWatch(bs, jsonTab5Path, 'jsonTab.json5'); + } + if (uiCapabilities.tabType === 'html') { + // Watch HTML tab files + const tabHtmlPath = path.resolve(this.rootPath, 'admin/tab.html'); + if (existsSync(tabHtmlPath)) { + bs.watch(tabHtmlPath, undefined, (e) => { + if (e === 'change') { + this.log.info('Detected change in tab.html, reloading browser...'); + // For HTML tabs, we rely on BrowserSync's automatic reload + } + }); + } + } + } + // Setup proxies + if (useBrowserSync) { + if (uiCapabilities.configType === 'html' || uiCapabilities.tabType === 'html') { + // browser-sync proxy for adapter files (for HTML config or HTML tabs) + const adminPattern = `/adapter/${this.adapterName}/**`; + pathRewrite[`^/adapter/${this.adapterName}/`] = '/'; + app.use(createProxyMiddleware([adminPattern, '/browser-sync/**'], { + target: `http://127.0.0.1:${browserSyncPort}`, + //ws: true, // can't have two web-socket connections proxying to different locations + pathRewrite, + })); + // admin proxy + app.use(createProxyMiddleware([`!${adminPattern}`, '!/browser-sync/**'], { + target: adminUrl, + ws: true, + })); + } + else { + // browser-sync proxy (for JSON config only) + app.use(createProxyMiddleware(['/browser-sync/**'], { + target: `http://127.0.0.1:${browserSyncPort}`, + // ws: true, // can't have two web-socket connections proxying to different locations + })); + // admin proxy + app.use(createProxyMiddleware({ + target: adminUrl, + ws: true, + })); + } + } + else { + // Direct admin proxy without browser-sync + app.use(createProxyMiddleware({ + target: adminUrl, + ws: true, + })); + } + } + createJsonConfigProxy(app, useBrowserSync = true) { + const jsonConfigFile = this.getJsonConfigPath(); + const adminUrl = `http://127.0.0.1:${this.getPort(HIDDEN_ADMIN_PORT_OFFSET)}`; + if (useBrowserSync) { + // Use BrowserSync for hot-reload functionality + const browserSyncPort = this.getPort(HIDDEN_BROWSER_SYNC_PORT_OFFSET); + const bs = this.startBrowserSync(browserSyncPort, false); + // Setup file watching for jsonConfig changes + this.setupJsonFileWatch(bs, jsonConfigFile, path.basename(jsonConfigFile)); + // "proxy" for the main page which injects our script + app.get('/', async (_req, res) => { + const { data } = await axios.get(adminUrl); + res.send(injectCode(data, this.adapterName, path.basename(jsonConfigFile))); + }); + // browser-sync proxy + app.use(createProxyMiddleware(['/browser-sync/**'], { + target: `http://127.0.0.1:${browserSyncPort}`, + // ws: true, // can't have two web-socket connections proxying to different locations + })); + // admin proxy + app.use(createProxyMiddleware({ + target: adminUrl, + ws: true, + })); + } + else { + // Serve without BrowserSync - just proxy admin directly + app.use(createProxyMiddleware({ + target: adminUrl, + ws: true, + })); + } + return Promise.resolve(); + } + async createHtmlConfigProxy(app, useBrowserSync = true) { + const pathRewrite = {}; + const adminPattern = `/adapter/${this.adapterName}/**`; + // Setup React build watching if needed + const hasReact = await this.setupReactWatch(pathRewrite); + if (useBrowserSync) { + // Use BrowserSync for hot-reload functionality + const browserSyncPort = this.getPort(HIDDEN_BROWSER_SYNC_PORT_OFFSET); + this.startBrowserSync(browserSyncPort, hasReact); + // browser-sync proxy + pathRewrite[`^/adapter/${this.adapterName}/`] = '/'; + app.use(createProxyMiddleware([adminPattern, '/browser-sync/**'], { + target: `http://127.0.0.1:${browserSyncPort}`, + //ws: true, // can't have two web-socket connections proxying to different locations + pathRewrite, + })); + // admin proxy + app.use(createProxyMiddleware([`!${adminPattern}`, '!/browser-sync/**'], { + target: `http://127.0.0.1:${this.getPort(HIDDEN_ADMIN_PORT_OFFSET)}`, + ws: true, + })); + } + else { + // Serve without BrowserSync - serve admin files directly and proxy the rest + const adminPath = path.resolve(this.rootPath, 'admin/'); + // serve static admin files + app.use(`/adapter/${this.adapterName}`, express.static(adminPath)); + // admin proxy for everything else + app.use(createProxyMiddleware([`!${adminPattern}`], { + target: `http://127.0.0.1:${this.getPort(HIDDEN_ADMIN_PORT_OFFSET)}`, + ws: true, + })); + } + } + /** + * Helper method to setup React build watching + * Returns true if React watching was started, false otherwise + */ + async setupReactWatch(pathRewrite) { + if (this.isJSController()) { + return false; + } + const pkg = await this.readPackageJson(); + const scripts = pkg.scripts; + if (!scripts) { + return false; + } + let hasReact = false; + if (scripts['watch:react']) { + await this.startReact('watch:react'); + hasReact = true; + if (existsSync(path.resolve(this.rootPath, 'admin/.watch'))) { + // rewrite the build directory to the .watch directory, + // because "watch:react" no longer updates the build directory automatically + pathRewrite[`^/adapter/${this.adapterName}/build/`] = '/.watch/'; + } + } + else if (scripts['watch:parcel']) { + // use React with legacy script name + await this.startReact('watch:parcel'); + hasReact = true; + } + return hasReact; + } + startBrowserSync(port, hasReact) { + this.log.notice('Starting browser-sync'); + const bs = browserSync.create(); + const adminPath = path.resolve(this.rootPath, 'admin/'); + const config = { + server: { baseDir: adminPath, directory: true }, + port: port, + open: false, + ui: false, + logLevel: 'info', + reloadDelay: hasReact ? 500 : 0, + reloadDebounce: hasReact ? 500 : 0, + files: [path.join(adminPath, '**')], + plugins: [ + { + module: 'bs-html-injector', + options: { + files: [path.join(adminPath, '*.html')], + }, + }, + ], + }; + // console.log(config); + bs.init(config); + return bs; + } + /** + * Helper method to setup file watching for a JSON config file (jsonConfig, jsonTab, etc.) + * Uploads the file to ioBroker via WebSocket when changes are detected + */ + setupJsonFileWatch(bs, filePath, fileName) { + if (!existsSync(filePath)) { + return; + } + bs.watch(filePath, undefined, async (e) => { + if (e === 'change') { + this.log.info(`Detected change in ${fileName}, uploading to ioBroker...`); + const content = await readFile(filePath); + this.websocket?.send(JSON.stringify([ + 3, + 46, + 'writeFile', + [`${this.adapterName}.admin`, fileName, Buffer.from(content).toString('base64')], + ])); + } + }); + } + async startReact(scriptName) { + this.log.notice('Starting React build'); + this.log.debug('Waiting for first successful React build...'); + await this.rootDir.spawnAndAwaitOutput('npm', ['run', scriptName], /(built in|done in|watching (files )?for)/i, { + shell: true, + }); + } + /** + * Patch an existing sourcemap file. + * + * @param src The path to the original sourcemap file to patch and copy. + * @param dest The relative path to the sourcemap file that is created. + */ + async patchSourcemap(src, dest) { + try { + const data = await readJson(src); + if (data.version !== 3) { + throw new Error(`Unsupported sourcemap version: ${data.version}`); + } + data.sourceRoot = path.dirname(src).replace(/\\/g, '/'); + await this.profileDir.writeJson(dest, data); + this.log.debug(`Patched ${dest} from ${src}`); + } + catch (error) { + this.log.warn(`Couldn't patch ${dest}: ${error}`); + } + } + /** + * Create an identity sourcemap to point to a different source file. + * + * @param src The path to the original JavaScript file. + * @param dest The relative path to the JavaScript file which will get a sourcemap attached. + * @param copyFromSrc Set to true to copy the JavaScript file from src to dest (not just modify dest). + */ + async addSourcemap(src, dest, copyFromSrc) { + try { + const mapFile = `${dest}.map`; + const data = await this.createIdentitySourcemap(src.replace(/\\/g, '/')); + await this.profileDir.writeJson(mapFile, data); + // append the sourcemap reference comment to the bottom of the file + const fileContent = copyFromSrc + ? await readFile(src, { encoding: 'utf-8' }) + : await this.profileDir.readFile(dest); + const filename = path.basename(mapFile); + let updatedContent = fileContent.replace(/(\/\/# sourceMappingURL=).+/, `$1${filename}`); + if (updatedContent === fileContent) { + // no existing source mapping URL was found in the file + if (!fileContent.endsWith('\n')) { + if (fileContent.match(/\r\n/)) { + // windows eol + updatedContent += '\r'; + } + updatedContent += '\n'; + } + updatedContent += `//# sourceMappingURL=${filename}`; + } + await this.profileDir.writeFile(dest, updatedContent); + this.log.debug(`Created ${mapFile} from ${src}`); + } + catch (error) { + this.log.warn(`Couldn't create reverse map for ${src}: ${error}`); + } + } + async createIdentitySourcemap(filename) { + // thanks to https://github.com/gulp-sourcemaps/identity-map/blob/251b51598d02e5aedaea8f1a475dfc42103a2727/lib/generate.js [MIT] + const generator = new SourceMapGenerator({ file: filename }); + const fileContent = await readFile(filename, { encoding: 'utf-8' }); + const tok = tokenizer(fileContent, { + ecmaVersion: 'latest', + allowHashBang: true, + locations: true, + }); + while (true) { + const token = tok.getToken(); + if (token.type.label === 'eof' || !token.loc) { + break; + } + const mapping = { + original: token.loc.start, + generated: token.loc.start, + source: filename, + }; + generator.addMapping(mapping); + } + return generator.toJSON(); + } + getFilePatterns(extensions, excludeAdmin) { + const exts = typeof extensions === 'string' ? [extensions] : extensions; + const patterns = exts.map(e => `./**/*.${e}`); + patterns.push('!./.*/**'); + patterns.push('!./**/node_modules/**'); + patterns.push('!./test/**'); + if (excludeAdmin) { + patterns.push('!./admin/**'); + } + return patterns; + } + async findFiles(extension, excludeAdmin) { + return await fg(this.getFilePatterns(extension, excludeAdmin), { cwd: this.rootPath }); + } +} diff --git a/dist/commands/Setup.js b/dist/commands/Setup.js new file mode 100644 index 00000000..d97b04a8 --- /dev/null +++ b/dist/commands/Setup.js @@ -0,0 +1,357 @@ +import { DBConnection } from '@iobroker/testing/build/tests/integration/lib/dbConnection.js'; +import chalk from 'chalk'; +import enquirer from 'enquirer'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { EOL, hostname } from 'node:os'; +import path from 'node:path'; +import { rimraf } from 'rimraf'; +import { CommandBase, HIDDEN_ADMIN_PORT_OFFSET, IOBROKER_COMMAND, OBJECTS_DB_PORT_OFFSET, STATES_DB_PORT_OFFSET, } from './CommandBase.js'; +import { escapeStringRegexp, writeJson } from './utils.js'; +export class Setup extends CommandBase { + adminPort; + dependencies; + backupFile; + force; + useSymlinks; + constructor(owner, adminPort, dependencies, backupFile, force, useSymlinks) { + super(owner); + this.adminPort = adminPort; + this.dependencies = dependencies; + this.backupFile = backupFile; + this.force = force; + this.useSymlinks = useSymlinks; + } + async doRun() { + if (this.force) { + this.log.notice(`Deleting ${this.profilePath}`); + await rimraf(this.profilePath); + } + if (this.owner.isSetUp()) { + this.log.error(`dev-server is already set up in "${this.profilePath}".`); + this.log.debug(`Use --force to set it up from scratch (all data will be lost).`); + return; + } + this.owner.config = { + adminPort: this.adminPort, + useSymlinks: this.useSymlinks, + }; + await this.buildLocalAdapter(); + this.log.notice(`Setting up in ${this.profilePath}`); + await this.setupDevServer(); + const commands = ['run', 'watch', 'debug']; + this.log.box(`dev-server was successfully set up in\n${this.profilePath}.\n\n` + + `You may now execute one of the following commands\n\n${commands + .map(command => `dev-server ${command} ${this.owner.profileName}`) + .join('\n')}\n\nto use dev-server.`); + } + async setupDevServer() { + // create the data directory + const dataDir = path.join(this.profilePath, 'iobroker-data'); + await mkdir(dataDir, { recursive: true }); + // create the configuration + const config = { + system: { + memoryLimitMB: 0, + hostname: `dev-${this.adapterName}-${hostname()}`, + instanceStartInterval: 2000, + compact: false, + allowShellCommands: false, + memLimitWarn: 100, + memLimitError: 50, + }, + multihostService: { + enabled: false, + }, + network: { + IPv4: true, + IPv6: false, + bindAddress: '127.0.0.1', + useSystemNpm: true, + }, + objects: { + type: 'jsonl', + host: '127.0.0.1', + port: this.getPort(OBJECTS_DB_PORT_OFFSET), + noFileCache: false, + maxQueue: 1000, + connectTimeout: 2000, + writeFileInterval: 5000, + dataDir: '', + options: { + auth_pass: null, + retry_max_delay: 5000, + retry_max_count: 19, + db: 0, + family: 0, + }, + }, + states: { + type: 'jsonl', + host: '127.0.0.1', + port: this.getPort(STATES_DB_PORT_OFFSET), + connectTimeout: 2000, + writeFileInterval: 30000, + dataDir: '', + options: { + auth_pass: null, + retry_max_delay: 5000, + retry_max_count: 19, + db: 0, + family: 0, + }, + }, + log: { + level: 'debug', + maxDays: 7, + noStdout: false, + transport: { + file1: { + type: 'file', + enabled: true, + filename: 'log/iobroker', + fileext: '.log', + maxsize: null, + maxFiles: null, + }, + }, + }, + plugins: {}, + dataDir: '../../iobroker-data/', + }; + await writeJson(path.join(dataDir, 'iobroker.json'), config); + // create the package file + if (this.isJSController()) { + // if this dev-server is used to debug JS-Controller, don't install a published version + delete this.dependencies['iobroker.js-controller']; + } + // Check if the adapter uses TypeScript and add esbuild-register dependency if needed + const adapterPkg = await this.readPackageJson(); + if (this.isTypeScriptMain(adapterPkg.main)) { + this.dependencies['@alcalzone/esbuild-register'] = '^2.5.1-1'; + } + const pkg = { + name: `dev-server.${this.adapterName}`, + version: '1.0.0', + private: true, + dependencies: this.dependencies, + 'dev-server': this.config, + }; + await writeJson(path.join(this.profilePath, 'package.json'), pkg); + // Tell npm to link the local adapter folder instead of creating a copy + if (this.config.useSymlinks) { + await writeFile(path.join(this.profilePath, '.npmrc'), 'install-links=false', 'utf8'); + } + await this.verifyIgnoreFiles(); + this.log.notice('Installing js-controller and admin...'); + await this.installDependencies(); + if (this.backupFile) { + const fullPath = path.resolve(this.backupFile); + this.log.notice(`Restoring backup from ${fullPath}`); + await this.profileDir.execWithExistingFile(fullPath, f => `${IOBROKER_COMMAND} restore "${f}"`); + } + if (this.isJSController()) { + await this.installLocalAdapter(); + } + await this.uploadAndAddAdapter('admin'); + // reconfigure admin instance (only listen to local IP address) + this.log.notice('Configure admin.0'); + await this.updateObject('system.adapter.admin.0', admin => { + admin.native.port = this.getPort(HIDDEN_ADMIN_PORT_OFFSET); + admin.native.bind = '127.0.0.1'; + return admin; + }); + if (!this.isJSController()) { + // install local adapter + await this.installLocalAdapter(); + await this.uploadAndAddAdapter(this.adapterName); + // installing any dependencies + const { common } = await this.readIoPackageJson(); + const adapterDeps = [ + ...this.getDependencies(common.dependencies), + ...this.getDependencies(common.globalDependencies), + ]; + this.log.debug(`Found ${adapterDeps.length} adapter dependencies`); + for (const adapter of adapterDeps) { + try { + await this.installRepoAdapter(adapter); + } + catch (error) { + this.log.debug(`Couldn't install iobroker.${adapter}: ${error}`); + } + } + this.log.notice(`Stop ${this.adapterName}.0`); + await this.updateObject(`system.adapter.${this.adapterName}.0`, adapter => { + adapter.common.enabled = false; + return adapter; + }); + } + this.log.notice(`Patching "system.config"`); + await this.updateObject('system.config', systemConfig => { + systemConfig.common.diag = 'none'; // Disable statistics reporting + systemConfig.common.licenseConfirmed = true; // Disable license confirmation + systemConfig.common.defaultLogLevel = 'debug'; // Set the default log level for adapters to debug + systemConfig.common.activeRepo = ['beta']; // Set adapter repository to beta + // Set other details to dummy values that they are not empty like in a normal installation + systemConfig.common.city = 'Berlin'; + systemConfig.common.country = 'Germany'; + systemConfig.common.longitude = 13.28; + systemConfig.common.latitude = 52.5; + systemConfig.common.language = 'en'; + systemConfig.common.tempUnit = '°C'; + systemConfig.common.currency = '€'; + return systemConfig; + }); + } + async installDependencies() { + await this.profileDir.exec('npm install --loglevel error --production'); + } + async verifyIgnoreFiles() { + this.log.notice(`Verifying .npmignore and .gitignore`); + let relative = path.relative(this.rootPath, this.owner.tempPath).replace('\\', '/'); + if (relative.startsWith('..')) { + // the temporary directory is outside the root, so no worries! + return; + } + if (!relative.endsWith('/')) { + relative += '/'; + } + const tempDirRegex = new RegExp(`\\s${escapeStringRegexp(relative) + .replace(/[\\/]$/, '') + .replace(/(\\\\|\/)/g, '[\\/]')}`); + const verifyFile = async (fileName, command, allowStar) => { + try { + const { stdout, stderr } = await this.rootDir.getExecOutput(command); + if (stdout.match(tempDirRegex) || stderr.match(tempDirRegex)) { + this.log.error(chalk.bold(`Your ${fileName} doesn't exclude the temporary directory "${relative}"`)); + const choices = []; + if (allowStar) { + choices.push({ + message: `Add wildcard to ${fileName} for ".*" (recommended)`, + name: 'add-star', + }); + } + choices.push({ + message: `Add "${relative}" to ${fileName}`, + name: 'add-explicit', + }, { + message: `Abort setup`, + name: 'abort', + }); + let action; + try { + const result = await enquirer.prompt({ + name: 'action', + type: 'select', + message: 'What would you like to do?', + choices, + }); + action = result.action; + } + catch { + action = 'abort'; + } + if (action === 'abort') { + return this.exit(-1); + } + const filepath = path.resolve(this.rootPath, fileName); + let content = ''; + if (existsSync(filepath)) { + content = await readFile(filepath, { encoding: 'utf-8' }); + } + const eol = content.match(/\r\n/) ? '\r\n' : content.match(/\n/) ? '\n' : EOL; + if (action === 'add-star') { + content = `# exclude all dot-files and directories${eol}.*${eol}${eol}${content}`; + } + else { + content = `${content}${eol}${eol}# ioBroker dev-server${eol}${relative}${eol}`; + } + await writeFile(filepath, content); + } + } + catch (error) { + this.log.debug(`Couldn't check ${fileName}: ${error}`); + } + }; + await verifyFile('.npmignore', 'npm pack --dry-run', true); + // Only verify .gitignore if we're in a git repository + if (existsSync(path.join(this.rootPath, '.git'))) { + await verifyFile('.gitignore', 'git status --short --untracked-files=all', false); + } + else { + this.log.debug('Skipping .gitignore verification: not in a git repository'); + } + } + async uploadAndAddAdapter(name) { + // upload the already installed adapter + await this.uploadAdapter(name); + if (await this.hasAdapterInstance(name)) { + this.log.info(`Instance ${name}.0 already exists, not adding it again`); + } + else { + // create an instance + this.log.notice(`Add ${name}.0`); + await this.profileDir.exec(`${IOBROKER_COMMAND} add ${name} 0`); + } + } + async hasAdapterInstance(name) { + return await this.withDb(async (db) => { + const instance = await db.getObject(`system.adapter.${name}.0`); + return !!instance; + }); + } + /** + * This method is largely borrowed from ioBroker.js-controller/lib/tools.js + * + * @param dependencies The global or local dependency list from io-package.json + * @returns the list of adapters (without js-controller) found in the dependencies. + */ + getDependencies(dependencies) { + const adapters = []; + if (Array.isArray(dependencies)) { + dependencies.forEach(rule => { + if (typeof rule === 'string') { + // No version given, all are okay + adapters.push(rule); + } + else { + // can be object containing a single adapter or multiple + Object.keys(rule) + .filter(adapter => !adapters.includes(adapter)) + .forEach(adapter => adapters.push(adapter)); + } + }); + } + else if (typeof dependencies === 'string') { + // its a single string without version requirement + adapters.push(dependencies); + } + else if (dependencies) { + adapters.push(...Object.keys(dependencies)); + } + return adapters.filter(a => a !== 'js-controller'); + } + async installRepoAdapter(adapterName) { + this.log.notice(`Install iobroker.${adapterName}`); + await this.profileDir.exec(`${IOBROKER_COMMAND} install ${adapterName}`); + } + async withDb(method) { + const db = new DBConnection('iobroker', this.profilePath, this.log); + await db.start(); + try { + return await method(db); + } + finally { + await db.stop(); + } + } + async updateObject(id, method) { + await this.withDb(async (db) => { + const obj = await db.getObject(id); + if (obj) { + // @ts-expect-error fix later + await db.setObject(id, method(obj)); + } + }); + } +} diff --git a/dist/commands/SetupRemote.js b/dist/commands/SetupRemote.js new file mode 100644 index 00000000..d3d483c0 --- /dev/null +++ b/dist/commands/SetupRemote.js @@ -0,0 +1,153 @@ +import enquirer from 'enquirer'; +import { randomUUID } from 'node:crypto'; +import { readdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { SemVer } from 'semver'; +import { IOBROKER_COMMAND } from './CommandBase.js'; +import { RemoteConnection } from './RemoteConnection.js'; +import { Setup } from './Setup.js'; +export class SetupRemote extends Setup { + remoteConnection; + constructor(owner, adminPort, dependencies, backupFile, force) { + super(owner, adminPort, dependencies, backupFile, force, false); + } + async setupDevServer() { + const { dependencies } = await this.owner.readMyPackageJson(); + this.dependencies.nodemon = dependencies.nodemon || 'latest'; + try { + await this.setupRemoteSsh(); + await super.setupDevServer(); + } + finally { + this.remoteConnection?.close(); + this.remoteConnection = undefined; + } + } + async setupRemoteSsh() { + const { host, port, user, auth, ...other } = await enquirer.prompt([ + { + name: 'host', + type: 'text', + message: 'Please enter the hostname or IP address of the remote host:', + initial: '', + }, + { + name: 'port', + type: 'number', + message: 'Please enter the SSH port of the remote host:', + initial: 22, + }, + { + name: 'user', + type: 'text', + message: 'Please enter the SSH username of the remote host:', + initial: 'root', + }, + { + name: 'auth', + type: 'select', + message: 'Please select the authentication method:', + choices: ['Password', 'SSH Key'], + }, + ]); + let privateKeyPath = undefined; + console.log({ host, port, user, auth, ...other }); + if (auth === 'SSH Key') { + const baseDir = path.join(homedir(), '.ssh'); + const files = await readdir(baseDir); + const keyFiles = files.filter(f => files.includes(`${f}.pub`)); + if (keyFiles.length > 0) { + const choices = keyFiles.map(f => path.join(baseDir, f)); + const MANUAL_OPTION = 'Enter path manually'; + choices.push(MANUAL_OPTION); + const response = await enquirer.prompt({ + name: 'keyFile', + type: 'select', + message: 'Please select the SSH key to use for authentication:', + choices, + }); + if (response.keyFile !== MANUAL_OPTION) { + privateKeyPath = response.keyFile; + } + } + if (!privateKeyPath) { + const response = await enquirer.prompt({ + name: 'keyFile', + type: 'text', + message: 'Please enter the path to the SSH key to use for authentication:', + initial: path.join(homedir(), '.ssh', 'id_rsa'), + }); + privateKeyPath = response.keyFile; + } + } + this.config.remote = { + id: randomUUID(), + host, + port, + user, + privateKeyPath, + }; + await this.connectRemote(); + await this.ensureRemoteReady(); + } + async installDependencies() { + await this.uploadToRemote('package.json'); + await this.uploadToRemote(path.join('iobroker-data', 'iobroker.json')); + this.log.notice('Installing dependencies on remote host...'); + await super.installDependencies(); + //await this.remoteConnection!.run("/usr/bin/bash -lic 'npm install --loglevel error --production'", ''); + } + async connectRemote() { + if (!this.config.remote) { + throw new Error('Remote configuration is missing'); + } + this.remoteConnection = new RemoteConnection(this.config.remote, this.log); + await this.remoteConnection.connect(); + this.profileDir = this.remoteConnection; + } + async ensureRemoteReady() { + this.log.notice('Ensuring remote host is ready for dev-server...'); + try { + const output = await this.remoteConnection.getExecOutput('which node'); + const nodePath = output.trim(); + if (!nodePath) { + throw new Error('Empty path'); + } + this.log.notice(`Remote Node.js path: ${nodePath}`); + } + catch (error) { + throw new Error('Node.js is not installed on the remote host', { cause: error }); + } + const nodeVersion = await this.remoteConnection.getExecOutput('node -v'); + this.log.notice(`Remote Node.js version: ${nodeVersion.trim()}`); + const version = new SemVer(nodeVersion.trim(), true); + if (version.major < 20) { + throw new Error(`Remote Node.js version must be 20 or higher`); + } + await this.remoteConnection.getExecOutput(`mkdir -p ~/.dev-server/${this.config.remote.id}/iobroker-data`); + } + async uploadToRemote(relPath) { + const localPath = path.join(this.profilePath, relPath); + await this.remoteConnection.upload(localPath, relPath); + } + async hasAdapterInstance(name) { + try { + await this.remoteConnection.getExecOutput(`cd ~/.dev-server/${this.config.remote.id} ; ${IOBROKER_COMMAND} object get system.adapter.${name}.0`); + return true; + } + catch { + return false; + } + } + async updateObject(id, method) { + const obj = { native: {}, common: {} }; + method(obj); + const command = `${IOBROKER_COMMAND} object extend ${id} '${JSON.stringify(obj)}'`; + await this.remoteConnection.exec(command); + } + withDb() { + // make sure this method is not used in remote setup + throw new Error('Method not supported for remote setup.'); + } +} diff --git a/dist/commands/Update.js b/dist/commands/Update.js new file mode 100644 index 00000000..45d13e09 --- /dev/null +++ b/dist/commands/Update.js @@ -0,0 +1,18 @@ +import { CommandBase } from './CommandBase.js'; +export class Update extends CommandBase { + async doRun() { + this.log.notice('Updating everything...'); + if (!this.config.useSymlinks) { + this.log.notice('Building local adapter.'); + await this.buildLocalAdapter(); + await this.installLocalAdapter(false); //do not install, keep .tgz file. + } + await this.profileDir.exec('npm update --loglevel error'); + await this.uploadAdapter('admin'); + await this.installLocalAdapter(); + if (!this.isJSController()) { + await this.uploadAdapter(this.adapterName); + } + this.log.box(`dev-server was successfully updated.`); + } +} diff --git a/dist/commands/Upload.js b/dist/commands/Upload.js new file mode 100644 index 00000000..9d14fd30 --- /dev/null +++ b/dist/commands/Upload.js @@ -0,0 +1,12 @@ +import { CommandBase } from './CommandBase.js'; +export class Upload extends CommandBase { + async doRun() { + await this.buildLocalAdapter(); + await this.installLocalAdapter(); + if (!this.isJSController()) { + await this.uploadAdapter(this.adapterName); + } + const target = this.config.remote?.host ?? this.profilePath; + this.log.box(`The latest content of iobroker.${this.adapterName} was uploaded to ${target}.`); + } +} diff --git a/dist/commands/Watch.js b/dist/commands/Watch.js new file mode 100644 index 00000000..73d75441 --- /dev/null +++ b/dist/commands/Watch.js @@ -0,0 +1,244 @@ +import chokidar from 'chokidar'; +import fg from 'fast-glob'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import nodemon from 'nodemon'; +import { RunCommandBase } from './RunCommandBase.js'; +import { delay } from './utils.js'; +export class Watch extends RunCommandBase { + startAdapter; + noInstall; + doNotWatch; + useBrowserSync; + constructor(owner, startAdapter, noInstall, doNotWatch, useBrowserSync) { + super(owner); + this.startAdapter = startAdapter; + this.noInstall = noInstall; + this.doNotWatch = doNotWatch; + this.useBrowserSync = useBrowserSync; + } + async doRun() { + if (!this.noInstall) { + await this.buildLocalAdapter(); + await this.installLocalAdapter(); + } + if (this.isJSController()) { + // this watches actually js-controller + await this.startAdapterWatch(); + await this.startServer(this.useBrowserSync); + } + else { + await this.startJsController(); + await this.startServer(this.useBrowserSync); + await this.startAdapterWatch(); + } + } + async startAdapterWatch() { + // figure out if we need to watch for TypeScript changes + const pkg = await this.readPackageJson(); + const scripts = pkg.scripts; + if (scripts && scripts['watch:ts']) { + this.log.notice(`Starting TypeScript watch: ${this.startAdapter}`); + // use TSC + await this.startTscWatch(); + } + const isTypeScriptMain = this.isTypeScriptMain(pkg.main); + const mainFileSuffix = pkg.main.split('.').pop(); + // start sync + const adapterRunDir = path.join('node_modules', `iobroker.${this.adapterName}`); + if (!this.config.useSymlinks) { + this.log.notice('Starting file synchronization'); + // This is not necessary when using symlinks + await this.startFileSync(adapterRunDir, mainFileSuffix); + this.log.notice('File synchronization ready'); + } + if (this.startAdapter) { + await delay(3000); + await this.startNodemon(adapterRunDir, pkg.main); + } + else { + const runner = isTypeScriptMain ? 'node -r @alcalzone/esbuild-register' : 'node'; + this.log.box(`You can now start the adapter manually by running\n ` + + `${runner} node_modules/iobroker.${this.adapterName}/${pkg.main} --debug 0\nfrom within\n ${this.profilePath}`); + } + } + async startTscWatch() { + this.log.notice('Starting tsc --watch'); + this.log.debug('Waiting for first successful tsc build...'); + await this.rootDir.spawnAndAwaitOutput('npm', ['run', 'watch:ts'], /watching (files )?for/i, { + shell: true, + }); + } + startFileSync(destinationDir, mainFileSuffix) { + this.log.debug(`Starting file system sync from ${this.rootPath} to ${destinationDir}`); + const inSrc = (filename) => path.join(this.rootPath, filename); + const inDest = (filename) => path.join(destinationDir, filename); + return new Promise((resolve, reject) => { + const patternList = ['js', 'map']; + if (!patternList.includes(mainFileSuffix)) { + patternList.push(mainFileSuffix); + } + const patterns = this.getFilePatterns(patternList, true); + const ignoreFiles = []; + const watcher = chokidar.watch(fg.sync(patterns), { cwd: this.rootPath }); + let ready = false; + let initialEventPromises = []; + watcher.on('error', reject); + watcher.on('ready', async () => { + this.log.debug('Initial scan complete. Ready for changes.'); + ready = true; + await Promise.all(initialEventPromises); + initialEventPromises = []; + resolve(); + }); + watcher.on('all', (event, path) => { + console.log(event, path); + }); + const syncFile = async (filename) => { + try { + this.log.debug(`Synchronizing ${filename}`); + const src = inSrc(filename); + const dest = inDest(filename); + if (filename.endsWith('.map')) { + await this.patchSourcemap(src, dest); + } + else if (!existsSync(inSrc(`${filename}.map`))) { + // copy file and add sourcemap + await this.addSourcemap(src, dest, true); + } + else { + await this.profileDir.copyFileTo(src, dest); + } + } + catch { + this.log.warn(`Couldn't sync ${filename}`); + } + }; + watcher.on('add', async (filename) => { + if (ready) { + await syncFile(filename); + } + else if (!filename.endsWith('map') && !(await this.profileDir.exists(inDest(filename)))) { + // ignore files during initial sync if they don't exist in the target directory (except for sourcemaps) + this.log.silly(`Ignoring file ${filename}`); + ignoreFiles.push(filename); + } + else { + initialEventPromises.push(syncFile(filename)); + } + }); + watcher.on('change', (filename) => { + if (!ignoreFiles.includes(filename)) { + const resPromise = syncFile(filename); + if (!ready) { + initialEventPromises.push(resPromise); + } + } + }); + watcher.on('unlink', async (filename) => { + await this.profileDir.unlink(inDest(filename)); + const map = inDest(`${filename}.map`); + if (await this.profileDir.exists(map)) { + await this.profileDir.unlink(map); + } + }); + }); + } + startNodemon(baseDir, scriptName) { + const fullBaseDir = path.resolve(this.profilePath, baseDir); + const script = path.resolve(fullBaseDir, scriptName); + this.log.notice(`Starting nodemon for ${script}`); + let isExiting = false; + process.on('SIGINT', () => { + isExiting = true; + }); + nodemon(this.createNodemonConfig(script, fullBaseDir)); + nodemon + .on('log', (msg) => { + if (isExiting) { + return; + } + const message = `[nodemon] ${msg.message}`; + switch (msg.type) { + case 'detail': + this.log.debug(message); + void this.handleNodemonDetailMsg(msg.message); + break; + case 'info': + this.log.info(message); + break; + case 'status': + this.log.notice(message); + break; + case 'fail': + this.log.error(message); + break; + case 'error': + this.log.warn(message); + break; + default: + this.log.debug(message); + break; + } + }) + .on('quit', () => { + this.log.error('nodemon has exited'); + return this.exit(-2); + }) + .on('crash', () => { + if (this.isJSController()) { + this.log.debug('nodemon has exited as expected'); + return this.exit(-1); + } + }); + if (!this.isJSController()) { + this.socketEvents.on('objectChange', (args) => { + if (Array.isArray(args) && args.length > 1 && args[0] === `system.adapter.${this.adapterName}.0`) { + this.log.notice('Adapter configuration changed, restarting nodemon...'); + nodemon.restart(); + } + }); + } + return Promise.resolve(); + } + createNodemonConfig(script, fullBaseDir) { + const args = this.isJSController() ? [] : ['--debug', '0']; + const ignoreList = [ + path.join(fullBaseDir, 'admin'), + // avoid recursively following symlinks + path.join(fullBaseDir, '.dev-server'), + ]; + if (this.doNotWatch.length > 0) { + this.doNotWatch.forEach(entry => ignoreList.push(path.join(fullBaseDir, entry))); + } + // Determine the appropriate execMap + const execMap = { + js: 'node --inspect --preserve-symlinks --preserve-symlinks-main', + mjs: 'node --inspect --preserve-symlinks --preserve-symlinks-main', + ts: 'node --inspect --preserve-symlinks --preserve-symlinks-main -r @alcalzone/esbuild-register', + }; + return { + script, + cwd: fullBaseDir, + stdin: false, + verbose: true, + // dump: true, // this will output the entire config and not do anything + colours: false, + watch: [fullBaseDir], + ignore: ignoreList, + ignoreRoot: [], + delay: 2000, + execMap, + signal: 'SIGINT', + args, + }; + } + async handleNodemonDetailMsg(message) { + const match = message.match(/child pid: (\d+)/); + if (!match) { + return; + } + const debugPid = await this.waitForNodeChildProcess(parseInt(match[1])); + this.log.box(`Debugger is now available on process id ${debugPid}`); + } +} diff --git a/dist/commands/WatchRemote.js b/dist/commands/WatchRemote.js new file mode 100644 index 00000000..19b1b272 --- /dev/null +++ b/dist/commands/WatchRemote.js @@ -0,0 +1,26 @@ +import path from 'node:path'; +import { RemoteConnection } from './RemoteConnection.js'; +import { ADAPTER_DEBUGGER_PORT } from './RunCommandBase.js'; +import { Watch } from './Watch.js'; +export class WatchRemote extends Watch { + async prepare() { + if (!this.startAdapter) { + throw new Error('Cannot watch remote adapter without starting it'); + } + await super.prepare(); + if (this.profileDir instanceof RemoteConnection) { + // this should always be the case + await this.profileDir.tunnelPort(ADAPTER_DEBUGGER_PORT); + } + } + async startNodemon(baseDir, scriptName) { + const script = path.join(baseDir, scriptName); + this.log.notice(`Starting nodemon for ${script} on remote host`); + await this.profileDir.writeJson('nodemon.json', this.createNodemonConfig(script, baseDir)); + await this.profileDir.spawn('npx', ['nodemon', '--config', 'nodemon.json', script], (exitCode) => { + this.log.warn(`Nodemon process on remote host has exited with exit code ${exitCode}`); + return this.exit(exitCode); + }); + this.log.box(`Debugger will be available on port 127.0.0.1:${ADAPTER_DEBUGGER_PORT}`); + } +} diff --git a/dist/commands/utils.js b/dist/commands/utils.js new file mode 100644 index 00000000..d7091f68 --- /dev/null +++ b/dist/commands/utils.js @@ -0,0 +1,54 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { Socket } from 'node:net'; +import psTree from 'ps-tree'; +export function escapeStringRegexp(value) { + // Escape characters with special meaning either inside or outside character sets. + // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar. + return value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); +} +export async function readJson(filePath) { + const content = await readFile(filePath, 'utf-8'); + return JSON.parse(content); +} +export async function writeJson(filePath, data) { + const content = JSON.stringify(data, null, 2); + await writeFile(filePath, content, 'utf-8'); +} +export function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} +export function checkPort(port, host = '127.0.0.1', timeout = 1000) { + return new Promise((resolve, reject) => { + const socket = new Socket(); + const onError = (error) => { + socket.destroy(); + reject(new Error(error)); + }; + socket.setTimeout(timeout); + socket.once('error', onError); + socket.once('timeout', onError); + socket.once('close', onError); + socket.connect(port, host, () => { + setTimeout(() => { + resolve(); + socket.end(); + }, 100); // slight delay to ensure port is ready + }); + }); +} +export function getChildProcesses(parentPid) { + return new Promise((resolve, reject) => psTree(parentPid, (err, children) => { + if (err) { + reject(err); + } + else { + // fix for MacOS bug #11 + children.forEach((c) => { + if (c.COMM && !c.COMMAND) { + c.COMMAND = c.COMM; + } + }); + resolve(children); + } + })); +} diff --git a/dist/index.js b/dist/index.js index 9982cb96..cc224098 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,1804 +1,2 @@ -#!/usr/bin/env node -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const yargs_1 = __importDefault(require("yargs/yargs")); -const dbConnection_1 = require("@iobroker/testing/build/tests/integration/lib/dbConnection"); -const axios_1 = __importDefault(require("axios")); -const browser_sync_1 = __importDefault(require("browser-sync")); -const chalk_1 = __importDefault(require("chalk")); -const cp = __importStar(require("node:child_process")); -const chokidar_1 = __importDefault(require("chokidar")); -const enquirer_1 = require("enquirer"); -const express_1 = __importDefault(require("express")); -const fast_glob_1 = __importDefault(require("fast-glob")); -const fs_extra_1 = require("fs-extra"); -const http_proxy_middleware_1 = require("http-proxy-middleware"); -const node_net_1 = require("node:net"); -const nodemon_1 = __importDefault(require("nodemon")); -const node_os_1 = require("node:os"); -const path = __importStar(require("node:path")); -const ps_tree_1 = __importDefault(require("ps-tree")); -const rimraf_1 = require("rimraf"); -const semver_1 = require("semver"); -const source_map_1 = require("source-map"); -const ws_1 = __importDefault(require("ws")); -const jsonConfig_1 = require("./jsonConfig"); -const logger_1 = require("./logger"); -const acorn_1 = __importDefault(require("acorn")); -const node_events_1 = __importDefault(require("node:events")); -const DEFAULT_TEMP_DIR_NAME = '.dev-server'; -const CORE_MODULE = 'iobroker.js-controller'; -const IOBROKER_CLI = 'node_modules/iobroker.js-controller/iobroker.js'; -const IOBROKER_COMMAND = `node ${IOBROKER_CLI}`; -const DEFAULT_ADMIN_PORT = 8081; -const HIDDEN_ADMIN_PORT_OFFSET = 12345; -const HIDDEN_BROWSER_SYNC_PORT_OFFSET = 14345; -const STATES_DB_PORT_OFFSET = 16345; -const OBJECTS_DB_PORT_OFFSET = 18345; -const DEFAULT_PROFILE_NAME = 'default'; -class DevServer { - constructor() { - this.socketEvents = new node_events_1.default(); - this.childProcesses = []; - const parser = (0, yargs_1.default)(process.argv.slice(2)); - void parser - .usage('Usage: $0 [options] [profile]\n or: $0 --help to see available options for a command') - .command(['setup [profile]', 's'], 'Set up dev-server in the current directory. This should always be called in the directory where the io-package.json file of your adapter is located.', { - adminPort: { - type: 'number', - default: DEFAULT_ADMIN_PORT, - alias: 'p', - description: 'TCP port on which ioBroker.admin will be available', - }, - jsController: { - type: 'string', - alias: 'j', - default: 'latest', - description: 'Define which version of js-controller to be used', - }, - admin: { - type: 'string', - alias: 'a', - default: 'latest', - description: 'Define which version of admin to be used', - }, - backupFile: { - type: 'string', - alias: 'b', - description: 'Provide an ioBroker backup file to restore in this dev-server', - }, - force: { type: 'boolean', hidden: true }, - symlinks: { - type: 'boolean', - alias: 'l', - default: false, - description: 'Use symlinks instead of packing and installing the current adapter for a smoother dev experience. Requires JS-Controller 5+.', - }, - }, async (args) => await this.setup(args.adminPort, { ['iobroker.js-controller']: args.jsController, ['iobroker.admin']: args.admin }, args.backupFile, !!args.force, args.symlinks)) - .command(['update [profile]', 'ud'], 'Update ioBroker and its dependencies to the latest versions', {}, async () => await this.update()) - .command(['run [profile]', 'r'], 'Run ioBroker dev-server, the adapter will not run, but you may test the Admin UI with hot-reload', { - noBrowserSync: { - type: 'boolean', - alias: 'b', - description: 'Do not use BrowserSync for hot-reload (serve static files instead)', - }, - }, async (args) => await this.run(!args.noBrowserSync)) - .command(['watch [profile]', 'w'], 'Run ioBroker dev-server and start the adapter in "watch" mode. The adapter will automatically restart when its source code changes. You may attach a debugger to the running adapter.', { - noStart: { - type: 'boolean', - alias: 'n', - description: 'Do not start the adapter itself, only watch for changes and sync them.', - }, - noInstall: { - type: 'boolean', - alias: 'x', - description: 'Do not build and install the adapter before starting.', - }, - doNotWatch: { - type: 'string', - alias: 'w', - description: 'Do not watch the given files or directories for changes (provide paths relative to the adapter base directory.', - }, - noBrowserSync: { - type: 'boolean', - alias: 'b', - description: 'Do not use BrowserSync for hot-reload (serve static files instead)', - }, - }, async (args) => await this.watch(!args.noStart, !!args.noInstall, args.doNotWatch, !args.noBrowserSync)) - .command(['debug [profile]', 'd'], 'Run ioBroker dev-server and start the adapter from ioBroker in "debug" mode. You may attach a debugger to the running adapter.', { - wait: { - type: 'boolean', - alias: 'w', - description: 'Start the adapter only once the debugger is attached.', - }, - noInstall: { - type: 'boolean', - alias: 'x', - description: 'Do not build and install the adapter before starting.', - }, - }, async (args) => await this.debug(!!args.wait, !!args.noInstall)) - .command(['upload [profile]', 'ul'], 'Upload the current version of your adapter to the ioBroker dev-server. This is only required if you changed something relevant in your io-package.json', {}, async () => await this.upload()) - .command(['backup [profile]', 'b'], 'Create an ioBroker backup to the given file.', {}, async (args) => await this.backup(args.filename)) - .command(['profile', 'p'], 'List all dev-server profiles that exist in the current directory.', {}, async () => await this.profile()) - .options({ - temp: { - type: 'string', - alias: 't', - default: DEFAULT_TEMP_DIR_NAME, - description: 'Temporary directory where the dev-server data will be located', - }, - root: { type: 'string', alias: 'r', hidden: true, default: '.' }, - verbose: { type: 'boolean', hidden: true, default: false }, - }) - .middleware(async (argv) => await this.setLogger(argv)) - .middleware(async () => await this.checkVersion()) - .middleware(async (argv) => await this.setDirectories(argv)) - .middleware(async () => await this.parseConfig()) - .wrap(Math.min(100, parser.terminalWidth())) - .help().argv; - } - setLogger(argv) { - this.log = new logger_1.Logger(argv.verbose ? 'silly' : 'debug'); - return Promise.resolve(); - } - async checkVersion() { - try { - const { name, version: localVersion } = JSON.parse((0, fs_extra_1.readFileSync)(path.join(__dirname, '..', 'package.json')).toString()); - const { data: { version: releaseVersion }, } = await axios_1.default.get(`https://registry.npmjs.org/${name}/latest`, { timeout: 1000 }); - if ((0, semver_1.gt)(releaseVersion, localVersion)) { - this.log.debug(`Found update from ${localVersion} to ${releaseVersion}`); - const response = await (0, enquirer_1.prompt)({ - name: 'update', - type: 'confirm', - message: `Version ${releaseVersion} of ${name} is available.\nWould you like to exit and update?`, - initial: true, - }); - if (response.update) { - this.log.box(`Please update ${name} manually and restart your last command afterwards.\n` + - `If you installed ${name} globally, you can simply call:\n\nnpm install --global ${name}`); - return this.exit(0); - } - this.log.warn(`We strongly recommend to update ${name} as soon as possible.`); - } - } - catch (_a) { - // ignore - } - } - async setDirectories(argv) { - this.rootDir = path.resolve(argv.root); - this.tempDir = path.resolve(this.rootDir, argv.temp); - if ((0, fs_extra_1.existsSync)(path.join(this.tempDir, 'package.json'))) { - // we are still in the old directory structure (no profiles), let's move it - const intermediateDir = path.join(this.rootDir, `${DEFAULT_TEMP_DIR_NAME}-temp`); - const defaultProfileDir = path.join(this.tempDir, DEFAULT_PROFILE_NAME); - this.log.debug(`Moving temporary data from ${this.tempDir} to ${defaultProfileDir}`); - await (0, fs_extra_1.rename)(this.tempDir, intermediateDir); - await (0, fs_extra_1.mkdir)(this.tempDir); - await (0, fs_extra_1.rename)(intermediateDir, defaultProfileDir); - } - let profileName = argv.profile; - const profiles = await this.getProfiles(); - const profileNames = Object.keys(profiles); - if (profileName) { - if (!argv._.includes('setup') && !argv._.includes('s')) { - // ensure the profile exists - if (!profileNames.includes(profileName)) { - throw new Error(`Profile ${profileName} doesn't exist`); - } - } - } - else { - if (argv._.includes('profile') || argv._.includes('p')) { - // we don't care about the profile name - profileName = DEFAULT_PROFILE_NAME; - } - else { - if (profileNames.length === 0) { - profileName = DEFAULT_PROFILE_NAME; - this.log.debug(`Using default profile ${profileName}`); - } - else if (profileNames.length === 1) { - profileName = profileNames[0]; - this.log.debug(`Using profile ${profileName}`); - } - else { - this.log.box(chalk_1.default.yellow(`You didn't specify the profile name in the command line. ` + - `You may do so the next time by appending the profile name to your command.\nExample:\n` + - `> dev-server ${process.argv.slice(2).join(' ')} ${profileNames[profileNames.length - 1]} `)); - const response = await (0, enquirer_1.prompt)({ - name: 'profile', - type: 'select', - message: 'Please choose a profile', - choices: profileNames.map(p => ({ - name: p, - hint: chalk_1.default.gray(`(Admin Port: ${profiles[p]['dev-server'].adminPort})`), - })), - }); - profileName = response.profile; - } - } - } - if (!profileName.match(/^[a-z0-9_-]+$/i)) { - throw new Error(`Invalid profile name: "${profileName}", it may only contain a-z, 0-9, _ and -.`); - } - this.profileName = profileName; - this.log.debug(`Using profile name "${this.profileName}"`); - this.profileDir = path.join(this.tempDir, profileName); - this.adapterName = await this.findAdapterName(); - } - async parseConfig() { - let pkg; - try { - pkg = await (0, fs_extra_1.readJson)(path.join(this.profileDir, 'package.json')); - } - catch (_a) { - // not all commands need the config - return; - } - this.config = pkg['dev-server']; - } - async findAdapterName() { - try { - const ioPackage = await (0, fs_extra_1.readJson)(path.join(this.rootDir, 'io-package.json')); - const adapterName = ioPackage.common.name; - this.log.debug(`Using adapter name "${adapterName}"`); - return adapterName; - } - catch (error) { - this.log.warn(error); - this.log.error('You must run dev-server in the adapter root directory (where io-package.json resides).'); - return this.exit(-1); - } - } - isJSController() { - return this.adapterName === 'js-controller'; - } - readPackageJson() { - return (0, fs_extra_1.readJson)(path.join(this.rootDir, 'package.json')); - } - /** - * Read and parse the io-package.json file from the adapter directory - * - * @returns Promise resolving to the parsed io-package.json content - */ - async readIoPackageJson() { - return (0, fs_extra_1.readJson)(path.join(this.rootDir, 'io-package.json')); - } - /** - * Detect adapter UI capabilities by reading io-package.json adminUi configuration - * - * This method determines how the adapter's configuration and tab UI should be handled - * by checking the adminUi field in io-package.json, which is the official ioBroker schema. - * It also checks for the presence of jsonConfig files to support legacy adapters. - * - * The detection logic replicates what the admin interface does to ensure dev-server - * behavior matches what users will see in production. - * - * @returns Promise resolving to an object containing: - * - configType: 'json' (jsonConfig), 'html' (HTML/React config), or 'none' - * - tabType: 'json' (jsonTab), 'html' (HTML/React tab), or 'none' - */ - async getAdapterUiCapabilities() { - var _a; - let configType = 'none'; - let tabType = 'none'; - // Check for jsonConfig files first - if (this.getJsonConfigPath()) { - configType = 'json'; - } - if (!this.isJSController()) { - // Check io-package.json adminUi field (replicate what admin does) - try { - const ioPackage = await this.readIoPackageJson(); - if ((_a = ioPackage === null || ioPackage === void 0 ? void 0 : ioPackage.common) === null || _a === void 0 ? void 0 : _a.adminUi) { - const adminUi = ioPackage.common.adminUi; - this.log.debug(`Found adminUi configuration in io-package.json: ${JSON.stringify(adminUi)}`); - // Set config type based on adminUi.config - if (adminUi.config === 'json') { - configType = 'json'; - } - else if (adminUi.config === 'html' || adminUi.config === 'materialize') { - configType = 'html'; - } - // Set tab type based on adminUi.tab - if (adminUi.tab === 'json') { - tabType = 'json'; - } - else if (adminUi.tab === 'html' || adminUi.tab === 'materialize') { - tabType = 'html'; - } - } - } - catch (error) { - this.log.debug(`Failed to read io-package.json adminUi: ${error}`); - } - } - this.log.debug(`UI capabilities: configType=${configType}, tabType=${tabType}`); - return { - configType, - tabType, - }; - } - isTypeScriptMain(mainFile) { - return !!(mainFile && mainFile.endsWith('.ts')); - } - getPort(adminPort, offset) { - let port = adminPort + offset; - if (port > 65000) { - port -= 63000; - } - return port; - } - getJsonConfigPath() { - const jsonConfigPath = path.resolve(this.rootDir, 'admin/jsonConfig.json'); - if ((0, fs_extra_1.existsSync)(jsonConfigPath)) { - return jsonConfigPath; - } - if ((0, fs_extra_1.existsSync)(`${jsonConfigPath}5`)) { - return `${jsonConfigPath}5`; - } - return ''; - } - ////////////////// Command Handlers ////////////////// - async setup(adminPort, dependencies, backupFile, force, useSymlinks = false) { - if (force) { - this.log.notice(`Deleting ${this.profileDir}`); - await (0, rimraf_1.rimraf)(this.profileDir); - } - if (this.isSetUp()) { - this.log.error(`dev-server is already set up in "${this.profileDir}".`); - this.log.debug(`Use --force to set it up from scratch (all data will be lost).`); - return; - } - await this.setupDevServer(adminPort, dependencies, backupFile, useSymlinks); - const commands = ['run', 'watch', 'debug']; - this.log.box(`dev-server was sucessfully set up in\n${this.profileDir}.\n\n` + - `You may now execute one of the following commands\n\n${commands - .map(command => `dev-server ${command} ${this.profileName}`) - .join('\n')}\n\nto use dev-server.`); - } - async update() { - var _a; - await this.checkSetup(); - this.log.notice('Updating everything...'); - if (!((_a = this.config) === null || _a === void 0 ? void 0 : _a.useSymlinks)) { - this.log.notice('Building local adapter.'); - await this.buildLocalAdapter(); - await this.installLocalAdapter(false); //do not install, keep .tgz file. - } - this.execSync('npm update --loglevel error', this.profileDir); - this.uploadAdapter('admin'); - await this.installLocalAdapter(); - if (!this.isJSController()) { - this.uploadAdapter(this.adapterName); - } - this.log.box(`dev-server was sucessfully updated.`); - } - async run(useBrowserSync = true) { - await this.checkSetup(); - await this.startJsController(); - await this.startServer(useBrowserSync); - } - async watch(startAdapter, noInstall, doNotWatch, useBrowserSync = true) { - let doNotWatchArr = []; - if (typeof doNotWatch === 'string') { - doNotWatchArr.push(doNotWatch); - } - else if (Array.isArray(doNotWatch)) { - doNotWatchArr = doNotWatch; - } - await this.checkSetup(); - if (!noInstall) { - await this.buildLocalAdapter(); - await this.installLocalAdapter(); - } - if (this.isJSController()) { - // this watches actually js-controller - await this.startAdapterWatch(startAdapter, doNotWatchArr); - await this.startServer(useBrowserSync); - } - else { - await this.startJsController(); - await this.startServer(useBrowserSync); - await this.startAdapterWatch(startAdapter, doNotWatchArr); - } - } - async debug(wait, noInstall) { - await this.checkSetup(); - if (!noInstall) { - await this.buildLocalAdapter(); - await this.installLocalAdapter(); - } - await this.copySourcemaps(); - if (this.isJSController()) { - await this.startJsControllerDebug(wait); - await this.startServer(); - } - else { - await this.startJsController(); - await this.startServer(); - await this.startAdapterDebug(wait); - } - } - async upload() { - await this.checkSetup(); - await this.buildLocalAdapter(); - await this.installLocalAdapter(); - if (!this.isJSController()) { - this.uploadAdapter(this.adapterName); - } - this.log.box(`The latest content of iobroker.${this.adapterName} was uploaded to ${this.profileDir}.`); - } - async backup(filename) { - const fullPath = path.resolve(filename); - this.log.notice('Creating backup'); - this.execSync(`${IOBROKER_COMMAND} backup "${fullPath}"`, this.profileDir); - return Promise.resolve(); - } - async profile() { - const profiles = await this.getProfiles(); - const table = Object.keys(profiles).map(name => { - const pkg = profiles[name]; - const infos = pkg['dev-server']; - const dependencies = pkg.dependencies; - return [ - name, - `http://127.0.0.1:${infos.adminPort}`, - dependencies['iobroker.js-controller'], - dependencies['iobroker.admin'], - ]; - }); - table.unshift([ - chalk_1.default.bold('Profile Name'), - chalk_1.default.bold('Admin URL'), - chalk_1.default.bold('js-controller'), - chalk_1.default.bold('admin'), - ]); - this.log.info(`The following profiles exist in ${this.tempDir}`); - this.log.table(table.filter(r => !!r)); - } - ////////////////// Command Helper Methods ////////////////// - async getProfiles() { - if (!(0, fs_extra_1.existsSync)(this.tempDir)) { - return {}; - } - const entries = await (0, fs_extra_1.readdir)(this.tempDir); - const pkgs = await Promise.all(entries.map(async (e) => { - try { - const pkg = await (0, fs_extra_1.readJson)(path.join(this.tempDir, e, 'package.json')); - const infos = pkg['dev-server']; - const dependencies = pkg.dependencies; - if ((infos === null || infos === void 0 ? void 0 : infos.adminPort) && dependencies) { - return [e, pkg]; - } - } - catch (_a) { - return undefined; - } - }, {})); - return pkgs.filter(p => !!p).reduce((old, [e, pkg]) => ({ ...old, [e]: pkg }), {}); - } - async checkSetup() { - if (!this.isSetUp()) { - this.log.error(`dev-server is not set up in ${this.profileDir}.\nPlease use the command "setup" first to set up dev-server.`); - return this.exit(-1); - } - } - isSetUp() { - const jsControllerDir = path.join(this.profileDir, 'node_modules', CORE_MODULE); - return (0, fs_extra_1.existsSync)(jsControllerDir); - } - checkPort(port, host = '127.0.0.1', timeout = 1000) { - return new Promise((resolve, reject) => { - const socket = new node_net_1.Socket(); - const onError = (error) => { - socket.destroy(); - reject(new Error(error)); - }; - socket.setTimeout(timeout); - socket.once('error', onError); - socket.once('timeout', onError); - socket.connect(port, host, () => { - socket.end(); - resolve(); - }); - }); - } - async waitForPort(port, offset = 0) { - port = this.getPort(port, offset); - this.log.debug(`Waiting for port ${port} to be available...`); - let tries = 0; - while (true) { - try { - await this.checkPort(port); - this.log.debug(`Port ${port} is available...`); - return true; - } - catch (_a) { - if (tries++ > 30) { - this.log.error(`Port ${port} is not available after 30 seconds.`); - return false; - } - await this.delay(1000); - } - } - } - async waitForJsController() { - if (!this.config) { - throw new Error(`Couldn't find dev-server configuration in package.json`); - } - if (!(await this.waitForPort(this.config.adminPort, OBJECTS_DB_PORT_OFFSET)) || - !(await this.waitForPort(this.config.adminPort, STATES_DB_PORT_OFFSET))) { - throw new Error(`Couldn't start js-controller`); - } - } - async startJsController() { - const proc = await this.spawn('node', [ - '--inspect=127.0.0.1:9228', - '--preserve-symlinks', - '--preserve-symlinks-main', - 'node_modules/iobroker.js-controller/controller.js', - ], this.profileDir); - proc.on('exit', async (code) => { - console.error(chalk_1.default.yellow(`ioBroker controller exited with code ${code}`)); - return this.exit(-1, 'SIGKILL'); - }); - this.log.notice('Waiting for js-controller to start...'); - await this.waitForJsController(); - } - async startJsControllerDebug(wait) { - this.log.notice(`Starting debugger for ${this.adapterName}`); - const nodeArgs = [ - '--preserve-symlinks', - '--preserve-symlinks-main', - 'node_modules/iobroker.js-controller/controller.js', - ]; - if (wait) { - nodeArgs.unshift('--inspect-brk'); - } - else { - nodeArgs.unshift('--inspect'); - } - const proc = await this.spawn('node', nodeArgs, this.profileDir); - proc.on('exit', code => { - console.error(chalk_1.default.yellow(`ioBroker controller exited with code ${code}`)); - return this.exit(-1); - }); - await this.waitForJsController(); - this.log.box(`Debugger is now ${wait ? 'waiting' : 'available'} on process id ${proc.pid}`); - } - async delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async startServer(useBrowserSync = true) { - this.log.notice(`Running inside ${this.profileDir}`); - if (!this.config) { - throw new Error(`Couldn't find dev-server configuration in package.json`); - } - const hiddenAdminPort = this.getPort(this.config.adminPort, HIDDEN_ADMIN_PORT_OFFSET); - await this.waitForPort(hiddenAdminPort); - const app = (0, express_1.default)(); - if (this.isJSController()) { - // simply forward admin as-is - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)({ - target: `http://127.0.0.1:${hiddenAdminPort}`, - ws: true, - })); - } - else { - // Determine what UI capabilities this adapter needs - const uiCapabilities = await this.getAdapterUiCapabilities(); - if (uiCapabilities.configType === 'json' && uiCapabilities.tabType !== 'none') { - // Adapter uses jsonConfig AND has tabs - support both simultaneously - await this.createCombinedConfigProxy(app, this.config, uiCapabilities, useBrowserSync); - } - else if (uiCapabilities.configType === 'json') { - // JSON config only - await this.createJsonConfigProxy(app, this.config, useBrowserSync); - } - else { - // HTML config or tabs only (or no config) - await this.createHtmlConfigProxy(app, this.config, useBrowserSync); - } - } - // start express - this.log.notice(`Starting web server on port ${this.config.adminPort}`); - const server = app.listen(this.config.adminPort); - let exiting = false; - process.on('SIGINT', () => { - this.log.notice('dev-server is exiting...'); - exiting = true; - server.close(); - // do not kill this process when receiving SIGINT, but let all child processes exit first - // but send the signal to all child processes when not in a tty environment - if (!process.stdin.isTTY) { - this.log.silly('Sending SIGINT to all child processes...'); - this.childProcesses.forEach(p => p.kill('SIGINT')); - } - }); - await new Promise((resolve, reject) => { - server.on('listening', resolve); - server.on('error', reject); - server.on('close', reject); - }); - if (!this.isJSController()) { - const connectWebSocketClient = () => { - if (exiting) { - return; - } - // TODO: replace this with @iobroker/socket-client - this.websocket = new ws_1.default(`ws://127.0.0.1:${hiddenAdminPort}/?sid=${Date.now()}&name=admin`); - this.websocket.on('open', () => this.log.silly('WebSocket open')); - this.websocket.on('close', () => { - this.log.silly('WebSocket closed'); - this.websocket = undefined; - setTimeout(connectWebSocketClient, 1000); - }); - this.websocket.on('error', error => this.log.silly(`WebSocket error: ${error}`)); - this.websocket.on('message', msg => { - var _a; - // eslint-disable-next-line @typescript-eslint/no-base-to-string - const msgString = msg === null || msg === void 0 ? void 0 : msg.toString(); - if (typeof msgString === 'string') { - try { - const data = JSON.parse(msgString); - if (!Array.isArray(data) || data.length === 0) { - return; - } - switch (data[0]) { - case 0: - if (data.length > 3) { - this.socketEvents.emit(data[2], data[3]); - } - break; - case 1: - // ping received, send pong (keep-alive) - (_a = this.websocket) === null || _a === void 0 ? void 0 : _a.send('[2]'); - break; - } - } - catch (error) { - this.log.error(`Couldn't handle WebSocket message: ${error}`); - } - } - }); - }; - connectWebSocketClient(); - } - this.log.box(`Admin is now reachable under http://127.0.0.1:${this.config.adminPort}/`); - } - /** - * Helper method to setup file watching for a JSON config file (jsonConfig, jsonTab, etc.) - * Uploads the file to ioBroker via WebSocket when changes are detected - */ - setupJsonFileWatch(bs, filePath, fileName) { - if (!(0, fs_extra_1.existsSync)(filePath)) { - return; - } - bs.watch(filePath, undefined, async (e) => { - var _a; - if (e === 'change') { - this.log.info(`Detected change in ${fileName}, uploading to ioBroker...`); - const content = await (0, fs_extra_1.readFile)(filePath); - (_a = this.websocket) === null || _a === void 0 ? void 0 : _a.send(JSON.stringify([ - 3, - 46, - 'writeFile', - [`${this.adapterName}.admin`, fileName, Buffer.from(content).toString('base64')], - ])); - } - }); - } - /** - * Helper method to setup React build watching - * Returns true if React watching was started, false otherwise - */ - async setupReactWatch(pathRewrite) { - if (this.isJSController()) { - return false; - } - const pkg = await this.readPackageJson(); - const scripts = pkg.scripts; - if (!scripts) { - return false; - } - let hasReact = false; - if (scripts['watch:react']) { - await this.startReact('watch:react'); - hasReact = true; - if ((0, fs_extra_1.existsSync)(path.resolve(this.rootDir, 'admin/.watch'))) { - // rewrite the build directory to the .watch directory, - // because "watch:react" no longer updates the build directory automatically - pathRewrite[`^/adapter/${this.adapterName}/build/`] = '/.watch/'; - } - } - else if (scripts['watch:parcel']) { - // use React with legacy script name - await this.startReact('watch:parcel'); - hasReact = true; - } - return hasReact; - } - createJsonConfigProxy(app, config, useBrowserSync = true) { - const jsonConfigFile = this.getJsonConfigPath(); - const adminUrl = `http://127.0.0.1:${this.getPort(config.adminPort, HIDDEN_ADMIN_PORT_OFFSET)}`; - if (useBrowserSync) { - // Use BrowserSync for hot-reload functionality - const browserSyncPort = this.getPort(config.adminPort, HIDDEN_BROWSER_SYNC_PORT_OFFSET); - const bs = this.startBrowserSync(browserSyncPort, false); - // Setup file watching for jsonConfig changes - this.setupJsonFileWatch(bs, jsonConfigFile, path.basename(jsonConfigFile)); - // "proxy" for the main page which injects our script - app.get('/', async (_req, res) => { - const { data } = await axios_1.default.get(adminUrl); - res.send((0, jsonConfig_1.injectCode)(data, this.adapterName, path.basename(jsonConfigFile))); - }); - // browser-sync proxy - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)(['/browser-sync/**'], { - target: `http://127.0.0.1:${browserSyncPort}`, - // ws: true, // can't have two web-socket connections proxying to different locations - })); - // admin proxy - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)({ - target: adminUrl, - ws: true, - })); - } - else { - // Serve without BrowserSync - just proxy admin directly - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)({ - target: adminUrl, - ws: true, - })); - } - return Promise.resolve(); - } - async createHtmlConfigProxy(app, config, useBrowserSync = true) { - const pathRewrite = {}; - const adminPattern = `/adapter/${this.adapterName}/**`; - // Setup React build watching if needed - const hasReact = await this.setupReactWatch(pathRewrite); - if (useBrowserSync) { - // Use BrowserSync for hot-reload functionality - const browserSyncPort = this.getPort(config.adminPort, HIDDEN_BROWSER_SYNC_PORT_OFFSET); - this.startBrowserSync(browserSyncPort, hasReact); - // browser-sync proxy - pathRewrite[`^/adapter/${this.adapterName}/`] = '/'; - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)([adminPattern, '/browser-sync/**'], { - target: `http://127.0.0.1:${browserSyncPort}`, - //ws: true, // can't have two web-socket connections proxying to different locations - pathRewrite, - })); - // admin proxy - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)([`!${adminPattern}`, '!/browser-sync/**'], { - target: `http://127.0.0.1:${this.getPort(config.adminPort, HIDDEN_ADMIN_PORT_OFFSET)}`, - ws: true, - })); - } - else { - // Serve without BrowserSync - serve admin files directly and proxy the rest - const adminPath = path.resolve(this.rootDir, 'admin/'); - // serve static admin files - app.use(`/adapter/${this.adapterName}`, express_1.default.static(adminPath)); - // admin proxy for everything else - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)([`!${adminPattern}`], { - target: `http://127.0.0.1:${this.getPort(config.adminPort, HIDDEN_ADMIN_PORT_OFFSET)}`, - ws: true, - })); - } - } - /** - * Create a combined config proxy that supports adapters using both jsonConfig and tabs - * - * This method merges the functionality of createJsonConfigProxy and createHtmlConfigProxy - * to support adapters that need both configuration UI types simultaneously. It handles: - * - React build watching for HTML-based config or tabs - * - JSON config file watching with WebSocket hot-reload - * - JSON tab file watching with WebSocket hot-reload - * - HTML tab file watching with BrowserSync automatic reload - * - Appropriate proxy routing based on the UI types present - * - * Used when an adapter has jsonConfig AND also has custom tabs (either HTML or JSON-based). - * For adapters with only one UI type, use createJsonConfigProxy or createHtmlConfigProxy instead. - * - * @param app Express application instance - * @param config Dev server configuration - * @param uiCapabilities Object containing configType and tabType detected from io-package.json - * @param useBrowserSync Whether to use BrowserSync for hot-reload (default: true) - */ - async createCombinedConfigProxy(app, config, uiCapabilities, useBrowserSync = true) { - // This method combines the functionality of createJsonConfigProxy and createHtmlConfigProxy - // to support adapters that use jsonConfig and tabs simultaneously - const pathRewrite = {}; - const browserSyncPort = this.getPort(config.adminPort, HIDDEN_BROWSER_SYNC_PORT_OFFSET); - const adminUrl = `http://127.0.0.1:${this.getPort(config.adminPort, HIDDEN_ADMIN_PORT_OFFSET)}`; - let hasReact = false; - let bs = null; - if (useBrowserSync) { - // Setup React build watching if needed (for HTML config or HTML tabs) - if (uiCapabilities.configType === 'html' || uiCapabilities.tabType === 'html') { - hasReact = await this.setupReactWatch(pathRewrite); - } - // Start browser-sync - bs = this.startBrowserSync(browserSyncPort, hasReact); - } - // Handle jsonConfig file watching if present - if (uiCapabilities.configType === 'json' && useBrowserSync && bs) { - const jsonConfigFile = this.getJsonConfigPath(); - this.setupJsonFileWatch(bs, jsonConfigFile, path.basename(jsonConfigFile)); - // "proxy" for the main page which injects our script - app.get('/', async (_req, res) => { - const { data } = await axios_1.default.get(adminUrl); - res.send((0, jsonConfig_1.injectCode)(data, this.adapterName, path.basename(jsonConfigFile))); - }); - } - // Handle tab file watching if present - if (uiCapabilities.tabType !== 'none' && useBrowserSync && bs) { - if (uiCapabilities.tabType === 'json') { - // Watch JSON tab files - const jsonTabPath = path.resolve(this.rootDir, 'admin/jsonTab.json'); - const jsonTab5Path = path.resolve(this.rootDir, 'admin/jsonTab.json5'); - this.setupJsonFileWatch(bs, jsonTabPath, 'jsonTab.json'); - this.setupJsonFileWatch(bs, jsonTab5Path, 'jsonTab.json5'); - } - if (uiCapabilities.tabType === 'html') { - // Watch HTML tab files - const tabHtmlPath = path.resolve(this.rootDir, 'admin/tab.html'); - if ((0, fs_extra_1.existsSync)(tabHtmlPath)) { - bs.watch(tabHtmlPath, undefined, (e) => { - if (e === 'change') { - this.log.info('Detected change in tab.html, reloading browser...'); - // For HTML tabs, we rely on BrowserSync's automatic reload - } - }); - } - } - } - // Setup proxies - if (useBrowserSync) { - if (uiCapabilities.configType === 'html' || uiCapabilities.tabType === 'html') { - // browser-sync proxy for adapter files (for HTML config or HTML tabs) - const adminPattern = `/adapter/${this.adapterName}/**`; - pathRewrite[`^/adapter/${this.adapterName}/`] = '/'; - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)([adminPattern, '/browser-sync/**'], { - target: `http://127.0.0.1:${browserSyncPort}`, - //ws: true, // can't have two web-socket connections proxying to different locations - pathRewrite, - })); - // admin proxy - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)([`!${adminPattern}`, '!/browser-sync/**'], { - target: adminUrl, - ws: true, - })); - } - else { - // browser-sync proxy (for JSON config only) - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)(['/browser-sync/**'], { - target: `http://127.0.0.1:${browserSyncPort}`, - // ws: true, // can't have two web-socket connections proxying to different locations - })); - // admin proxy - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)({ - target: adminUrl, - ws: true, - })); - } - } - else { - // Direct admin proxy without browser-sync - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)({ - target: adminUrl, - ws: true, - })); - } - } - async copySourcemaps() { - const outDir = path.join(this.profileDir, 'node_modules', `iobroker.${this.adapterName}`); - this.log.notice(`Creating or patching sourcemaps in ${outDir}`); - const sourcemaps = await this.findFiles('map', true); - if (sourcemaps.length === 0) { - this.log.debug(`Couldn't find any sourcemaps in ${this.rootDir},\nwill try to reverse map .js files`); - // search all .js files that exist in the node module in the temp directory as well as in the root directory and - // create sourcemap files for each of them - const jsFiles = await this.findFiles('js', true); - await Promise.all(jsFiles.map(async (js) => { - const src = path.join(this.rootDir, js); - const dest = path.join(outDir, js); - await this.addSourcemap(src, dest, false); - })); - return; - } - // copy all *.map files to the node module in the temp directory and - // change their sourceRoot so they can be found in the development directory - await Promise.all(sourcemaps.map(async (sourcemap) => { - const src = path.join(this.rootDir, sourcemap); - const dest = path.join(outDir, sourcemap); - await this.patchSourcemap(src, dest); - })); - } - /** - * Create an identity sourcemap to point to a different source file. - * - * @param src The path to the original JavaScript file. - * @param dest The path to the JavaScript file which will get a sourcemap attached. - * @param copyFromSrc Set to true to copy the JavaScript file from src to dest (not just modify dest). - */ - async addSourcemap(src, dest, copyFromSrc) { - try { - const mapFile = `${dest}.map`; - const data = await this.createIdentitySourcemap(src.replace(/\\/g, '/')); - await (0, fs_extra_1.writeFile)(mapFile, JSON.stringify(data)); - // append the sourcemap reference comment to the bottom of the file - const fileContent = await (0, fs_extra_1.readFile)(copyFromSrc ? src : dest, { encoding: 'utf-8' }); - const filename = path.basename(mapFile); - let updatedContent = fileContent.replace(/(\/\/# sourceMappingURL=).+/, `$1${filename}`); - if (updatedContent === fileContent) { - // no existing source mapping URL was found in the file - if (!fileContent.endsWith('\n')) { - if (fileContent.match(/\r\n/)) { - // windows eol - updatedContent += '\r'; - } - updatedContent += '\n'; - } - updatedContent += `//# sourceMappingURL=${filename}`; - } - await (0, fs_extra_1.writeFile)(dest, updatedContent); - this.log.debug(`Created ${mapFile} from ${src}`); - } - catch (error) { - this.log.warn(`Couldn't reverse map for ${src}: ${error}`); - } - } - /** - * Patch an existing sourcemap file. - * - * @param src The path to the original sourcemap file to patch and copy. - * @param dest The path to the sourcemap file that is created. - */ - async patchSourcemap(src, dest) { - try { - const data = await (0, fs_extra_1.readJson)(src); - if (data.version !== 3) { - throw new Error(`Unsupported sourcemap version: ${data.version}`); - } - data.sourceRoot = path.dirname(src).replace(/\\/g, '/'); - await (0, fs_extra_1.writeJson)(dest, data); - this.log.debug(`Patched ${dest} from ${src}`); - } - catch (error) { - this.log.warn(`Couldn't patch ${dest}: ${error}`); - } - } - getFilePatterns(extensions, excludeAdmin) { - const exts = typeof extensions === 'string' ? [extensions] : extensions; - const patterns = exts.map(e => `./**/*.${e}`); - patterns.push('!./.*/**'); - patterns.push('!./**/node_modules/**'); - patterns.push('!./test/**'); - if (excludeAdmin) { - patterns.push('!./admin/**'); - } - return patterns; - } - async findFiles(extension, excludeAdmin) { - return await (0, fast_glob_1.default)(this.getFilePatterns(extension, excludeAdmin), { cwd: this.rootDir }); - } - async createIdentitySourcemap(filename) { - // thanks to https://github.com/gulp-sourcemaps/identity-map/blob/251b51598d02e5aedaea8f1a475dfc42103a2727/lib/generate.js [MIT] - const generator = new source_map_1.SourceMapGenerator({ file: filename }); - const fileContent = await (0, fs_extra_1.readFile)(filename, { encoding: 'utf-8' }); - const tokenizer = acorn_1.default.tokenizer(fileContent, { - ecmaVersion: 'latest', - allowHashBang: true, - locations: true, - }); - while (true) { - const token = tokenizer.getToken(); - if (token.type.label === 'eof' || !token.loc) { - break; - } - const mapping = { - original: token.loc.start, - generated: token.loc.start, - source: filename, - }; - generator.addMapping(mapping); - } - return generator.toJSON(); - } - async startReact(scriptName) { - this.log.notice('Starting React build'); - this.log.debug('Waiting for first successful React build...'); - await this.spawnAndAwaitOutput('npm', ['run', scriptName], this.rootDir, /(built in|done in|watching (files )?for)/i, { - shell: true, - }); - } - startBrowserSync(port, hasReact) { - this.log.notice('Starting browser-sync'); - const bs = browser_sync_1.default.create(); - const adminPath = path.resolve(this.rootDir, 'admin/'); - const config = { - server: { baseDir: adminPath, directory: true }, - port: port, - open: false, - ui: false, - logLevel: 'info', - reloadDelay: hasReact ? 500 : 0, - reloadDebounce: hasReact ? 500 : 0, - files: [path.join(adminPath, '**')], - plugins: [ - { - module: 'bs-html-injector', - options: { - files: [path.join(adminPath, '*.html')], - }, - }, - ], - }; - // console.log(config); - bs.init(config); - return bs; - } - async startAdapterDebug(wait) { - this.log.notice(`Starting ioBroker adapter debugger for ${this.adapterName}.0`); - const args = [ - '--preserve-symlinks', - '--preserve-symlinks-main', - IOBROKER_CLI, - 'debug', - `${this.adapterName}.0`, - ]; - if (wait) { - args.push('--wait'); - } - const proc = await this.spawn('node', args, this.profileDir); - proc.on('exit', code => { - console.error(chalk_1.default.yellow(`Adapter debugging exited with code ${code}`)); - return this.exit(-1); - }); - if (!proc.pid) { - throw new Error(`PID of adapter debugger unknown!`); - } - const debugPid = await this.waitForNodeChildProcess(proc.pid); - this.log.box(`Debugger is now ${wait ? 'waiting' : 'available'} on process id ${debugPid}`); - } - async waitForNodeChildProcess(parentPid) { - const start = new Date().getTime(); - while (start + 2000 > new Date().getTime()) { - const processes = await this.getChildProcesses(parentPid); - const child = processes.find(p => p.COMMAND.match(/node/i)); - if (child) { - return parseInt(child.PID); - } - } - this.log.debug(`No node child process of ${parentPid} found, assuming parent process was reused.`); - return parentPid; - } - getChildProcesses(parentPid) { - return new Promise((resolve, reject) => (0, ps_tree_1.default)(parentPid, (err, children) => { - if (err) { - reject(err); - } - else { - // fix for MacOS bug #11 - children.forEach((c) => { - if (c.COMM && !c.COMMAND) { - c.COMMAND = c.COMM; - } - }); - resolve(children); - } - })); - } - async startAdapterWatch(startAdapter, doNotWatch) { - var _a; - // figure out if we need to watch for TypeScript changes - const pkg = await this.readPackageJson(); - const scripts = pkg.scripts; - if (scripts && scripts['watch:ts']) { - this.log.notice(`Starting TypeScript watch: ${startAdapter}`); - // use TSC - await this.startTscWatch(); - } - const isTypeScriptMain = this.isTypeScriptMain(pkg.main); - const mainFileSuffix = pkg.main.split('.').pop(); - // start sync - const adapterRunDir = path.join(this.profileDir, 'node_modules', `iobroker.${this.adapterName}`); - if (!((_a = this.config) === null || _a === void 0 ? void 0 : _a.useSymlinks)) { - this.log.notice('Starting file synchronization'); - // This is not necessary when using symlinks - await this.startFileSync(adapterRunDir, mainFileSuffix); - this.log.notice('File synchronization ready'); - } - if (startAdapter) { - await this.delay(3000); - this.log.notice('Starting Nodemon'); - await this.startNodemon(adapterRunDir, pkg.main, doNotWatch); - } - else { - const runner = isTypeScriptMain ? 'node -r @alcalzone/esbuild-register' : 'node'; - this.log.box(`You can now start the adapter manually by running\n ` + - `${runner} node_modules/iobroker.${this.adapterName}/${pkg.main} --debug 0\nfrom within\n ${this.profileDir}`); - } - } - async startTscWatch() { - this.log.notice('Starting tsc --watch'); - this.log.debug('Waiting for first successful tsc build...'); - await this.spawnAndAwaitOutput('npm', ['run', 'watch:ts'], this.rootDir, /watching (files )?for/i, { - shell: true, - }); - } - startFileSync(destinationDir, mainFileSuffix) { - this.log.notice(`Starting file system sync from ${this.rootDir}`); - const inSrc = (filename) => path.join(this.rootDir, filename); - const inDest = (filename) => path.join(destinationDir, filename); - return new Promise((resolve, reject) => { - const patternList = ['js', 'map']; - if (!patternList.includes(mainFileSuffix)) { - patternList.push(mainFileSuffix); - } - const patterns = this.getFilePatterns(patternList, true); - const ignoreFiles = []; - const watcher = chokidar_1.default.watch(fast_glob_1.default.sync(patterns), { cwd: this.rootDir }); - let ready = false; - let initialEventPromises = []; - watcher.on('error', reject); - watcher.on('ready', async () => { - this.log.debug('Initial scan complete. Ready for changes.'); - ready = true; - await Promise.all(initialEventPromises); - initialEventPromises = []; - resolve(); - }); - watcher.on('all', (event, path) => { - console.log(event, path); - }); - const syncFile = async (filename) => { - try { - this.log.debug(`Synchronizing ${filename}`); - const src = inSrc(filename); - const dest = inDest(filename); - if (filename.endsWith('.map')) { - await this.patchSourcemap(src, dest); - } - else if (!(0, fs_extra_1.existsSync)(inSrc(`${filename}.map`))) { - // copy file and add sourcemap - await this.addSourcemap(src, dest, true); - } - else { - await (0, fs_extra_1.copyFile)(src, dest); - } - } - catch (_a) { - this.log.warn(`Couldn't sync ${filename}`); - } - }; - watcher.on('add', (filename) => { - if (ready) { - void syncFile(filename); - } - else if (!filename.endsWith('map') && !(0, fs_extra_1.existsSync)(inDest(filename))) { - // ignore files during initial sync if they don't exist in the target directory (except for sourcemaps) - ignoreFiles.push(filename); - } - else { - initialEventPromises.push(syncFile(filename)); - } - }); - watcher.on('change', (filename) => { - if (!ignoreFiles.includes(filename)) { - const resPromise = syncFile(filename); - if (!ready) { - initialEventPromises.push(resPromise); - } - } - }); - watcher.on('unlink', (filename) => { - (0, fs_extra_1.unlinkSync)(inDest(filename)); - const map = inDest(`${filename}.map`); - if ((0, fs_extra_1.existsSync)(map)) { - (0, fs_extra_1.unlinkSync)(map); - } - }); - }); - } - startNodemon(baseDir, scriptName, doNotWatch) { - const script = path.resolve(baseDir, scriptName); - this.log.notice(`Starting nodemon for ${script}`); - let isExiting = false; - process.on('SIGINT', () => { - isExiting = true; - }); - const args = this.isJSController() ? [] : ['--debug', '0']; - const ignoreList = [ - path.join(baseDir, 'admin'), - // avoid recursively following symlinks - path.join(baseDir, '.dev-server'), - ]; - if (doNotWatch.length > 0) { - doNotWatch.forEach(entry => ignoreList.push(path.join(baseDir, entry))); - } - // Determine the appropriate execMap - const execMap = { - js: 'node --inspect --preserve-symlinks --preserve-symlinks-main', - mjs: 'node --inspect --preserve-symlinks --preserve-symlinks-main', - ts: 'node --inspect --preserve-symlinks --preserve-symlinks-main -r @alcalzone/esbuild-register', - }; - (0, nodemon_1.default)({ - script, - cwd: baseDir, - stdin: false, - verbose: true, - // dump: true, // this will output the entire config and not do anything - colours: false, - watch: [baseDir], - ignore: ignoreList, - ignoreRoot: [], - delay: 2000, - execMap, - signal: 'SIGINT', // wrong type definition: signal is of type "string?" - args, - }); - nodemon_1.default - .on('log', (msg) => { - if (isExiting) { - return; - } - const message = `[nodemon] ${msg.message}`; - switch (msg.type) { - case 'detail': - this.log.debug(message); - void this.handleNodemonDetailMsg(msg.message); - break; - case 'info': - this.log.info(message); - break; - case 'status': - this.log.notice(message); - break; - case 'fail': - this.log.error(message); - break; - case 'error': - this.log.warn(message); - break; - default: - this.log.debug(message); - break; - } - }) - .on('quit', () => { - this.log.error('nodemon has exited'); - return this.exit(-2); - }) - .on('crash', () => { - if (this.isJSController()) { - this.log.debug('nodemon has exited as expected'); - return this.exit(-1); - } - }); - if (!this.isJSController()) { - this.socketEvents.on('objectChange', (args) => { - if (Array.isArray(args) && args.length > 1 && args[0] === `system.adapter.${this.adapterName}.0`) { - this.log.notice('Adapter configuration changed, restarting nodemon...'); - nodemon_1.default.restart(); - } - }); - } - return Promise.resolve(); - } - async handleNodemonDetailMsg(message) { - const match = message.match(/child pid: (\d+)/); - if (!match) { - return; - } - const debugPid = await this.waitForNodeChildProcess(parseInt(match[1])); - this.log.box(`Debugger is now available on process id ${debugPid}`); - } - async setupDevServer(adminPort, dependencies, backupFile, useSymlinks) { - await this.buildLocalAdapter(); - this.log.notice(`Setting up in ${this.profileDir}`); - this.config = { - adminPort, - useSymlinks, - }; - // create the data directory - const dataDir = path.join(this.profileDir, 'iobroker-data'); - await (0, fs_extra_1.mkdirp)(dataDir); - // create the configuration - const config = { - system: { - memoryLimitMB: 0, - hostname: `dev-${this.adapterName}-${(0, node_os_1.hostname)()}`, - instanceStartInterval: 2000, - compact: false, - allowShellCommands: false, - memLimitWarn: 100, - memLimitError: 50, - }, - multihostService: { - enabled: false, - }, - network: { - IPv4: true, - IPv6: false, - bindAddress: '127.0.0.1', - useSystemNpm: true, - }, - objects: { - type: 'jsonl', - host: '127.0.0.1', - port: this.getPort(adminPort, OBJECTS_DB_PORT_OFFSET), - noFileCache: false, - maxQueue: 1000, - connectTimeout: 2000, - writeFileInterval: 5000, - dataDir: '', - options: { - auth_pass: null, - retry_max_delay: 5000, - retry_max_count: 19, - db: 0, - family: 0, - }, - }, - states: { - type: 'jsonl', - host: '127.0.0.1', - port: this.getPort(adminPort, STATES_DB_PORT_OFFSET), - connectTimeout: 2000, - writeFileInterval: 30000, - dataDir: '', - options: { - auth_pass: null, - retry_max_delay: 5000, - retry_max_count: 19, - db: 0, - family: 0, - }, - }, - log: { - level: 'debug', - maxDays: 7, - noStdout: false, - transport: { - file1: { - type: 'file', - enabled: true, - filename: 'log/iobroker', - fileext: '.log', - maxsize: null, - maxFiles: null, - }, - }, - }, - plugins: {}, - dataDir: '../../iobroker-data/', - }; - await (0, fs_extra_1.writeJson)(path.join(dataDir, 'iobroker.json'), config, { spaces: 2 }); - // create the package file - if (this.isJSController()) { - // if this dev-server is used to debug JS-Controller, don't install a published version - delete dependencies['iobroker.js-controller']; - } - // Check if the adapter uses TypeScript and add esbuild-register dependency if needed - const adapterPkg = await this.readPackageJson(); - if (this.isTypeScriptMain(adapterPkg.main)) { - dependencies['@alcalzone/esbuild-register'] = '^2.5.1-1'; - } - const pkg = { - name: `dev-server.${this.adapterName}`, - version: '1.0.0', - private: true, - dependencies, - 'dev-server': { - adminPort, - useSymlinks, - }, - }; - await (0, fs_extra_1.writeJson)(path.join(this.profileDir, 'package.json'), pkg, { spaces: 2 }); - // Tell npm to link the local adapter folder instead of creating a copy - if (useSymlinks) { - await (0, fs_extra_1.writeFile)(path.join(this.profileDir, '.npmrc'), 'install-links=false', 'utf8'); - } - await this.verifyIgnoreFiles(); - this.log.notice('Installing js-controller and admin...'); - this.execSync('npm install --loglevel error --production', this.profileDir); - if (backupFile) { - const fullPath = path.resolve(backupFile); - this.log.notice(`Restoring backup from ${fullPath}`); - this.execSync(`${IOBROKER_COMMAND} restore "${fullPath}"`, this.profileDir); - } - if (this.isJSController()) { - await this.installLocalAdapter(); - } - await this.uploadAndAddAdapter('admin'); - // reconfigure admin instance (only listen to local IP address) - this.log.notice('Configure admin.0'); - await this.updateObject('system.adapter.admin.0', admin => { - admin.native.port = this.getPort(adminPort, HIDDEN_ADMIN_PORT_OFFSET); - admin.native.bind = '127.0.0.1'; - return admin; - }); - if (!this.isJSController()) { - // install local adapter - await this.installLocalAdapter(); - await this.uploadAndAddAdapter(this.adapterName); - // installing any dependencies - const { common } = await (0, fs_extra_1.readJson)(path.join(this.rootDir, 'io-package.json')); - const adapterDeps = [ - ...this.getDependencies(common.dependencies), - ...this.getDependencies(common.globalDependencies), - ]; - this.log.debug(`Found ${adapterDeps.length} adapter dependencies`); - for (const adapter of adapterDeps) { - try { - await this.installRepoAdapter(adapter); - } - catch (error) { - this.log.debug(`Couldn't install iobroker.${adapter}: ${error}`); - } - } - this.log.notice(`Stop ${this.adapterName}.0`); - await this.updateObject(`system.adapter.${this.adapterName}.0`, adapter => { - adapter.common.enabled = false; - return adapter; - }); - } - this.log.notice(`Patching "system.config"`); - await this.updateObject('system.config', systemConfig => { - systemConfig.common.diag = 'none'; // Disable statistics reporting - systemConfig.common.licenseConfirmed = true; // Disable license confirmation - systemConfig.common.defaultLogLevel = 'debug'; // Set the default log level for adapters to debug - systemConfig.common.activeRepo = ['beta']; // Set adapter repository to beta - // Set other details to dummy values that they are not empty like in a normal installation - systemConfig.common.city = 'Berlin'; - systemConfig.common.country = 'Germany'; - systemConfig.common.longitude = 13.28; - systemConfig.common.latitude = 52.5; - systemConfig.common.language = 'en'; - systemConfig.common.tempUnit = '°C'; - systemConfig.common.currency = '€'; - return systemConfig; - }); - } - isGitRepository() { - // Check if we're in a git repository by looking for .git directory - return (0, fs_extra_1.existsSync)(path.join(this.rootDir, '.git')); - } - async verifyIgnoreFiles() { - this.log.notice(`Verifying .npmignore and .gitignore`); - let relative = path.relative(this.rootDir, this.tempDir).replace('\\', '/'); - if (relative.startsWith('..')) { - // the temporary directory is outside the root, so no worries! - return; - } - if (!relative.endsWith('/')) { - relative += '/'; - } - const tempDirRegex = new RegExp(`\\s${this.escapeStringRegexp(relative) - .replace(/[\\/]$/, '') - .replace(/(\\\\|\/)/g, '[\\/]')}`); - const verifyFile = async (fileName, command, allowStar) => { - try { - const { stdout, stderr } = await this.getExecOutput(command, this.rootDir); - if (stdout.match(tempDirRegex) || stderr.match(tempDirRegex)) { - this.log.error(chalk_1.default.bold(`Your ${fileName} doesn't exclude the temporary directory "${relative}"`)); - const choices = []; - if (allowStar) { - choices.push({ - message: `Add wildcard to ${fileName} for ".*" (recommended)`, - name: 'add-star', - }); - } - choices.push({ - message: `Add "${relative}" to ${fileName}`, - name: 'add-explicit', - }, { - message: `Abort setup`, - name: 'abort', - }); - let action; - try { - const result = await (0, enquirer_1.prompt)({ - name: 'action', - type: 'select', - message: 'What would you like to do?', - choices, - }); - action = result.action; - } - catch (_a) { - action = 'abort'; - } - if (action === 'abort') { - return this.exit(-1); - } - const filepath = path.resolve(this.rootDir, fileName); - let content = ''; - if ((0, fs_extra_1.existsSync)(filepath)) { - content = await (0, fs_extra_1.readFile)(filepath, { encoding: 'utf-8' }); - } - const eol = content.match(/\r\n/) ? '\r\n' : content.match(/\n/) ? '\n' : node_os_1.EOL; - if (action === 'add-star') { - content = `# exclude all dot-files and directories${eol}.*${eol}${eol}${content}`; - } - else { - content = `${content}${eol}${eol}# ioBroker dev-server${eol}${relative}${eol}`; - } - await (0, fs_extra_1.writeFile)(filepath, content); - } - } - catch (error) { - this.log.debug(`Couldn't check ${fileName}: ${error}`); - } - }; - await verifyFile('.npmignore', 'npm pack --dry-run', true); - // Only verify .gitignore if we're in a git repository - if (this.isGitRepository()) { - await verifyFile('.gitignore', 'git status --short --untracked-files=all', false); - } - else { - this.log.debug('Skipping .gitignore verification: not in a git repository'); - } - } - async uploadAndAddAdapter(name) { - // upload the already installed adapter - this.uploadAdapter(name); - if (await this.withDb(async (db) => { - const instance = await db.getObject(`system.adapter.${name}.0`); - if (instance) { - this.log.info(`Instance ${name}.0 already exists, not adding it again`); - return false; - } - return true; - })) { - // create an instance - this.log.notice(`Add ${name}.0`); - this.execSync(`${IOBROKER_COMMAND} add ${name} 0`, this.profileDir); - } - } - uploadAdapter(name) { - this.log.notice(`Upload iobroker.${name}`); - this.execSync(`${IOBROKER_COMMAND} upload ${name}`, this.profileDir); - } - async buildLocalAdapter() { - var _a; - const pkg = await this.readPackageJson(); - if ((_a = pkg.scripts) === null || _a === void 0 ? void 0 : _a.build) { - this.log.notice(`Build iobroker.${this.adapterName}`); - this.execSync('npm run build', this.rootDir); - } - } - async installLocalAdapter(doInstall = true) { - var _a, _b; - this.log.notice(`Install local iobroker.${this.adapterName}`); - if ((_a = this.config) === null || _a === void 0 ? void 0 : _a.useSymlinks) { - // This is the expected relative path - const relativePath = path.relative(this.profileDir, this.rootDir); - // Check if it is already used in package.json - const tempPkg = await (0, fs_extra_1.readJson)(path.join(this.profileDir, 'package.json')); - const depPath = (_b = tempPkg.dependencies) === null || _b === void 0 ? void 0 : _b[`iobroker.${this.adapterName}`]; - // If not, install it - if (depPath !== relativePath) { - this.execSync(`npm install "${relativePath}"`, this.profileDir); - } - } - else { - const { stdout } = await this.getExecOutput('npm pack', this.rootDir); - const filename = stdout.trim(); - this.log.info(`Packed to ${filename}`); - if (doInstall) { - const fullPath = path.join(this.rootDir, filename); - this.execSync(`npm install "${fullPath}"`, this.profileDir); - await (0, rimraf_1.rimraf)(fullPath); - } - } - } - installRepoAdapter(adapterName) { - this.log.notice(`Install iobroker.${adapterName}`); - this.execSync(`${IOBROKER_COMMAND} install ${adapterName}`, this.profileDir); - return Promise.resolve(); - } - /** - * This method is largely borrowed from ioBroker.js-controller/lib/tools.js - * - * @param dependencies The global or local dependency list from io-package.json - * @returns the list of adapters (without js-controller) found in the dependencies. - */ - getDependencies(dependencies) { - const adapters = []; - if (Array.isArray(dependencies)) { - dependencies.forEach(rule => { - if (typeof rule === 'string') { - // No version given, all are okay - adapters.push(rule); - } - else { - // can be object containing a single adapter or multiple - Object.keys(rule) - .filter(adapter => !adapters.includes(adapter)) - .forEach(adapter => adapters.push(adapter)); - } - }); - } - else if (typeof dependencies === 'string') { - // its a single string without version requirement - adapters.push(dependencies); - } - else if (dependencies) { - adapters.push(...Object.keys(dependencies)); - } - return adapters.filter(a => a !== 'js-controller'); - } - async withDb(method) { - const db = new dbConnection_1.DBConnection('iobroker', this.profileDir, this.log); - await db.start(); - try { - return await method(db); - } - finally { - await db.stop(); - } - } - async updateObject(id, method) { - await this.withDb(async (db) => { - const obj = await db.getObject(id); - if (obj) { - // @ts-expect-error fix later - await db.setObject(id, method(obj)); - } - }); - } - execSync(command, cwd, options) { - options = { cwd: cwd, stdio: 'inherit', ...options }; - this.log.debug(`${cwd}> ${command}`); - return cp.execSync(command, options); - } - getExecOutput(command, cwd) { - this.log.debug(`${cwd}> ${command}`); - return new Promise((resolve, reject) => { - this.childProcesses.push(cp.exec(command, { cwd, encoding: 'ascii' }, (err, stdout, stderr) => { - if (err) { - reject(err); - } - else { - resolve({ stdout, stderr }); - } - })); - }); - } - spawn(command, args, cwd, options) { - return new Promise((resolve, reject) => { - let processSpawned = false; - this.log.debug(`${cwd}> ${command} ${args.join(' ')}`); - const proc = cp.spawn(command, args, { - stdio: ['ignore', 'inherit', 'inherit'], - cwd: cwd, - ...options, - }); - this.childProcesses.push(proc); - let alive = true; - proc.on('spawn', () => { - processSpawned = true; - resolve(proc); - }); - proc.on('error', err => { - this.log.error(`Could not spawn ${command}: ${err}`); - if (!processSpawned) { - reject(err); - } - }); - proc.on('exit', () => (alive = false)); - process.on('exit', () => alive && proc.kill('SIGINT')); - }); - } - async spawnAndAwaitOutput(command, args, cwd, awaitMsg, options) { - const proc = await this.spawn(command, args, cwd, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); - return new Promise((resolve, reject) => { - var _a, _b; - const handleStream = (isStderr) => (data) => { - let str = data.toString('utf-8'); - // eslint-disable-next-line no-control-regex - str = str.replace(/\x1Bc/, ''); // filter the "clear screen" ANSI code (used by tsc) - if (str) { - str = str.trimEnd(); - if (isStderr) { - console.error(str); - } - else { - console.log(str); - } - } - if (typeof awaitMsg === 'string') { - if (str.includes(awaitMsg)) { - resolve(proc); - } - } - else { - if (awaitMsg.test(str)) { - resolve(proc); - } - } - }; - (_a = proc.stdout) === null || _a === void 0 ? void 0 : _a.on('data', handleStream(false)); - (_b = proc.stderr) === null || _b === void 0 ? void 0 : _b.on('data', handleStream(true)); - proc.on('exit', code => reject(new Error(`Exited with ${code}`))); - process.on('SIGINT', () => { - proc.kill('SIGINT'); - reject(new Error('SIGINT')); - }); - }); - } - escapeStringRegexp(value) { - // Escape characters with special meaning either inside or outside character sets. - // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar. - return value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); - } - async exit(exitCode, signal = 'SIGINT') { - const childPids = this.childProcesses.map(p => p.pid).filter(p => !!p); - const tryKill = (pid, signal) => { - try { - process.kill(pid, signal); - } - catch (_a) { - // ignore - } - }; - try { - const children = await Promise.all(childPids.map(pid => this.getChildProcesses(pid))); - children.forEach(ch => ch.forEach(c => tryKill(parseInt(c.PID), signal))); - } - catch (error) { - this.log.error(`Couldn't kill grand-child processes: ${error}`); - } - if (childPids.length) { - childPids.forEach(pid => tryKill(pid, signal)); - if (signal !== 'SIGKILL') { - // first try SIGINT and give it 5s to exit itself before killing the processes left - await this.delay(5000); - return this.exit(exitCode, 'SIGKILL'); - } - } - process.exit(exitCode); - } -} +import { DevServer } from './DevServer.js'; (() => new DevServer())(); diff --git a/dist/jsonConfig.js b/dist/jsonConfig.js index f66955d5..d2893c10 100644 --- a/dist/jsonConfig.js +++ b/dist/jsonConfig.js @@ -1,7 +1,4 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.injectCode = injectCode; -function injectCode(html, adapterName, jsonConfigFileName) { +export function injectCode(html, adapterName, jsonConfigFileName) { return html.replace('', `