From d7e8d37669cddc6b7b3f7eff53e91483ad26abe3 Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Mon, 12 Jan 2026 09:19:57 +0100 Subject: [PATCH 01/25] Automatically fixed settings --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b71b3c15..8928b7fb 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]": { From cf62b516c7b315d4433765ee90a99084e34b8c1c Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Mon, 12 Jan 2026 09:20:42 +0100 Subject: [PATCH 02/25] Update license year --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From b679a81dee40ccdab1ca43bdc7c8d6da5d7d273f Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Mon, 12 Jan 2026 08:40:20 +0000 Subject: [PATCH 03/25] Remove unnecessary dev dependency --- package-lock.json | 12 ------------ package.json | 1 - 2 files changed, 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 821dcf48..72d3b9a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,6 @@ "@types/node": "^25.0.3", "@types/ps-tree": "^1.1.6", "@types/semver": "^7.7.1", - "@types/table": "^6.3.2", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", "mocha": "^11.7.5", @@ -1205,17 +1204,6 @@ "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", "license": "MIT" }, - "node_modules/@types/table": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/@types/table/-/table-6.3.2.tgz", - "integrity": "sha512-GJ82z3vQbx2BhiUo12w2A3lyBpXPJrGHjQ7iS5aH925098w8ojqiWBhgOUy97JS2PKLmRCTLT0sI+gJI4futig==", - "deprecated": "This is a stub types definition. table provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "table": "*" - } - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", diff --git a/package.json b/package.json index 1cc33ebd..eeb21b3c 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "@types/node": "^25.0.3", "@types/ps-tree": "^1.1.6", "@types/semver": "^7.7.1", - "@types/table": "^6.3.2", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", "mocha": "^11.7.5", From f02d3adc3e88ca1ba4144c931d27375531d35ded Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Mon, 12 Jan 2026 10:12:49 +0000 Subject: [PATCH 04/25] Code cleanup --- .vscode/settings.json | 3 ++- dist/index.js | 12 ++++++------ src/index.ts | 12 ++++++------ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8928b7fb..70b53ba6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,5 +22,6 @@ "[markdown]": { "editor.tabSize": 4, "editor.insertSpaces": true - } + }, + "cSpell.words": ["iobroker"] } diff --git a/dist/index.js b/dist/index.js index 9982cb96..ad00f72f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -37,31 +37,31 @@ 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 acorn_1 = __importDefault(require("acorn")); 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 cp = __importStar(require("node:child_process")); +const node_events_1 = __importDefault(require("node:events")); 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 nodemon_1 = __importDefault(require("nodemon")); 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 yargs_1 = __importDefault(require("yargs/yargs")); 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'; @@ -409,7 +409,7 @@ class DevServer { if (!this.isJSController()) { this.uploadAdapter(this.adapterName); } - this.log.box(`dev-server was sucessfully updated.`); + this.log.box(`dev-server was successfully updated.`); } async run(useBrowserSync = true) { await this.checkSetup(); diff --git a/src/index.ts b/src/index.ts index f709c49e..b03053e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,10 @@ #!/usr/bin/env node -import yargs from 'yargs/yargs'; import { DBConnection } from '@iobroker/testing/build/tests/integration/lib/dbConnection'; +import acorn from 'acorn'; import axios from 'axios'; import browserSync from 'browser-sync'; import chalk from 'chalk'; -import * as cp from 'node:child_process'; import chokidar from 'chokidar'; import { prompt } from 'enquirer'; import express, { type Application } from 'express'; @@ -25,19 +24,20 @@ import { writeJson, } from 'fs-extra'; import { legacyCreateProxyMiddleware as createProxyMiddleware } from 'http-proxy-middleware'; +import * as cp from 'node:child_process'; +import EventEmitter from 'node:events'; import { Socket } from 'node:net'; -import nodemon from 'nodemon'; import { EOL, hostname } from 'node:os'; import * as path from 'node:path'; +import nodemon from 'nodemon'; import psTree from 'ps-tree'; import { rimraf } from 'rimraf'; import { gt } from 'semver'; import { type RawSourceMap, SourceMapGenerator } from 'source-map'; import WebSocket from 'ws'; +import yargs from 'yargs/yargs'; import { injectCode } from './jsonConfig'; import { Logger } from './logger'; -import acorn from 'acorn'; -import EventEmitter from 'node:events'; const DEFAULT_TEMP_DIR_NAME = '.dev-server'; const CORE_MODULE = 'iobroker.js-controller'; @@ -503,7 +503,7 @@ class DevServer { this.uploadAdapter(this.adapterName); } - this.log.box(`dev-server was sucessfully updated.`); + this.log.box(`dev-server was successfully updated.`); } async run(useBrowserSync = true): Promise { From c5176e405eaf93fafed1242e81521aeefd0a474e Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Mon, 12 Jan 2026 16:42:17 +0000 Subject: [PATCH 05/25] Refactor code into one class per command --- .vscode/settings.json | 2 +- dist/DevServer.js | 381 ++++++ dist/commands/Backup.js | 14 + dist/commands/CommandBase.js | 291 +++++ dist/commands/Debug.js | 77 ++ dist/commands/Run.js | 15 + dist/commands/RunCommandBase.js | 601 +++++++++ dist/commands/Setup.js | 336 +++++ dist/commands/Update.js | 23 + dist/commands/Upload.js | 14 + dist/commands/Watch.js | 244 ++++ dist/commands/utils.js | 30 + dist/index.js | 1804 +------------------------- src/DevServer.ts | 479 +++++++ src/commands/Backup.ts | 15 + src/commands/CommandBase.ts | 291 +++++ src/commands/Debug.ts | 81 ++ src/commands/Run.ts | 16 + src/commands/RunCommandBase.ts | 710 +++++++++++ src/commands/Setup.ts | 372 ++++++ src/commands/Update.ts | 23 + src/commands/Upload.ts | 12 + src/commands/Watch.ts | 258 ++++ src/commands/utils.ts | 31 + src/index.ts | 2106 +------------------------------ 25 files changed, 4318 insertions(+), 3908 deletions(-) create mode 100644 dist/DevServer.js create mode 100644 dist/commands/Backup.js create mode 100644 dist/commands/CommandBase.js create mode 100644 dist/commands/Debug.js create mode 100644 dist/commands/Run.js create mode 100644 dist/commands/RunCommandBase.js create mode 100644 dist/commands/Setup.js create mode 100644 dist/commands/Update.js create mode 100644 dist/commands/Upload.js create mode 100644 dist/commands/Watch.js create mode 100644 dist/commands/utils.js create mode 100644 src/DevServer.ts create mode 100644 src/commands/Backup.ts create mode 100644 src/commands/CommandBase.ts create mode 100644 src/commands/Debug.ts create mode 100644 src/commands/Run.ts create mode 100644 src/commands/RunCommandBase.ts create mode 100644 src/commands/Setup.ts create mode 100644 src/commands/Update.ts create mode 100644 src/commands/Upload.ts create mode 100644 src/commands/Watch.ts create mode 100644 src/commands/utils.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 70b53ba6..ca171117 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,5 +23,5 @@ "editor.tabSize": 4, "editor.insertSpaces": true }, - "cSpell.words": ["iobroker"] + "cSpell.words": ["alcalzone", "iobroker"] } diff --git a/dist/DevServer.js b/dist/DevServer.js new file mode 100644 index 00000000..7766171b --- /dev/null +++ b/dist/DevServer.js @@ -0,0 +1,381 @@ +#!/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 }); +exports.DevServer = void 0; +const axios_1 = __importDefault(require("axios")); +const chalk_1 = __importDefault(require("chalk")); +const enquirer_1 = require("enquirer"); +const fs_extra_1 = require("fs-extra"); +const path = __importStar(require("node:path")); +const semver_1 = require("semver"); +const yargs_1 = __importDefault(require("yargs/yargs")); +const Backup_1 = require("./commands/Backup"); +const Debug_1 = require("./commands/Debug"); +const Run_1 = require("./commands/Run"); +const Setup_1 = require("./commands/Setup"); +const Update_1 = require("./commands/Update"); +const Upload_1 = require("./commands/Upload"); +const Watch_1 = require("./commands/Watch"); +const logger_1 = require("./logger"); +const DEFAULT_TEMP_DIR_NAME = '.dev-server'; +const CORE_MODULE = 'iobroker.js-controller'; +const DEFAULT_ADMIN_PORT = 8081; +const DEFAULT_PROFILE_NAME = 'default'; +class DevServer { + constructor() { + 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', + }, + remote: { + type: 'boolean', + alias: 'r', + description: 'Run ioBroker and the adapter on a remote host', + }, + 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())) + .help().argv; + } + setLogger(argv) { + this.log = new logger_1.Logger(argv.verbose ? 'silly' : 'debug'); + return Promise.resolve(); + } + async checkVersion() { + try { + const { name, version: localVersion } = await this.readMyPackageJson(); + 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 process.exit(0); + } + this.log.warn(`We strongly recommend to update ${name} as soon as possible.`); + } + } + catch (_a) { + // ignore + } + } + async readMyPackageJson() { + return (0, fs_extra_1.readJson)(path.join(__dirname, '..', 'package.json')); + } + 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 process.exit(-1); + } + } + ////////////////// Command Handlers ////////////////// + async setup(adminPort, dependencies, backupFile, remote, force, useSymlinks) { + const setup = new Setup_1.Setup(this, adminPort, dependencies, backupFile, force, useSymlinks); + await setup.run(); + } + async update() { + await this.checkSetup(); + const update = new Update_1.Update(this); + await update.run(); + } + async run(useBrowserSync = true) { + await this.checkSetup(); + const run = new Run_1.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; + } + await this.checkSetup(); + const watch = new Watch_1.Watch(this, noInstall, startAdapter, doNotWatchArr, useBrowserSync); + await watch.run(); + } + async debug(wait, noInstall) { + await this.checkSetup(); + const debug = new Debug_1.Debug(this, wait, noInstall); + await debug.run(); + } + async upload() { + await this.checkSetup(); + const upload = new Upload_1.Upload(this); + await upload.run(); + this.log.box(`The latest content of iobroker.${this.adapterName} was uploaded to ${this.profileDir}.`); + } + async backup(filename) { + await this.checkSetup(); + this.log.notice('Creating backup'); + const fullPath = path.resolve(filename); + const backup = new Backup_1.Backup(this, fullPath); + 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}`, + 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 process.exit(-1); + } + } + isSetUp() { + const jsControllerDir = path.join(this.profileDir, 'node_modules', CORE_MODULE); + return (0, fs_extra_1.existsSync)(jsControllerDir); + } +} +exports.DevServer = DevServer; +(() => new DevServer())(); diff --git a/dist/commands/Backup.js b/dist/commands/Backup.js new file mode 100644 index 00000000..7eca9f7b --- /dev/null +++ b/dist/commands/Backup.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Backup = void 0; +const CommandBase_1 = require("./CommandBase"); +class Backup extends CommandBase_1.CommandBase { + constructor(owner, filename) { + super(owner); + this.filename = filename; + } + async run() { + this.execSync(`${CommandBase_1.IOBROKER_COMMAND} backup "${this.filename}"`, this.profileDir); + } +} +exports.Backup = Backup; diff --git a/dist/commands/CommandBase.js b/dist/commands/CommandBase.js new file mode 100644 index 00000000..e50e584a --- /dev/null +++ b/dist/commands/CommandBase.js @@ -0,0 +1,291 @@ +"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 }); +exports.CommandBase = exports.OBJECTS_DB_PORT_OFFSET = exports.STATES_DB_PORT_OFFSET = exports.HIDDEN_BROWSER_SYNC_PORT_OFFSET = exports.HIDDEN_ADMIN_PORT_OFFSET = exports.IOBROKER_COMMAND = exports.IOBROKER_CLI = void 0; +const dbConnection_1 = require("@iobroker/testing/build/tests/integration/lib/dbConnection"); +const fs_extra_1 = require("fs-extra"); +const cp = __importStar(require("node:child_process")); +const node_path_1 = __importDefault(require("node:path")); +const ps_tree_1 = __importDefault(require("ps-tree")); +const rimraf_1 = require("rimraf"); +const utils_1 = require("./utils"); +exports.IOBROKER_CLI = 'node_modules/iobroker.js-controller/iobroker.js'; +exports.IOBROKER_COMMAND = `node ${exports.IOBROKER_CLI}`; +exports.HIDDEN_ADMIN_PORT_OFFSET = 12345; +exports.HIDDEN_BROWSER_SYNC_PORT_OFFSET = 14345; +exports.STATES_DB_PORT_OFFSET = 16345; +exports.OBJECTS_DB_PORT_OFFSET = 18345; +class CommandBase { + constructor(owner) { + this.owner = owner; + this.childProcesses = []; + } + get log() { + return this.owner.log; + } + get rootDir() { + return this.owner.rootDir; + } + get profileDir() { + return this.owner.profileDir; + } + get profileName() { + return this.owner.profileName; + } + get adapterName() { + return this.owner.adapterName; + } + get config() { + if (!this.owner.config) { + throw new Error('DevServer is not configured yet'); + } + return this.owner.config; + } + getPort(offset) { + return this.config.adminPort + offset; + } + isJSController() { + return this.adapterName === 'js-controller'; + } + readPackageJson() { + return (0, fs_extra_1.readJson)(node_path_1.default.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)(node_path_1.default.join(this.rootDir, 'io-package.json')); + } + isTypeScriptMain(mainFile) { + return !!(mainFile && mainFile.endsWith('.ts')); + } + async installLocalAdapter(doInstall = true) { + var _a; + this.log.notice(`Install local iobroker.${this.adapterName}`); + if (this.config.useSymlinks) { + // This is the expected relative path + const relativePath = node_path_1.default.relative(this.profileDir, this.rootDir); + // Check if it is already used in package.json + const tempPkg = await (0, fs_extra_1.readJson)(node_path_1.default.join(this.profileDir, 'package.json')); + const depPath = (_a = tempPkg.dependencies) === null || _a === void 0 ? void 0 : _a[`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 = node_path_1.default.join(this.rootDir, filename); + this.execSync(`npm install "${fullPath}"`, this.profileDir); + await (0, rimraf_1.rimraf)(fullPath); + } + } + } + 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); + } + } + uploadAdapter(name) { + this.log.notice(`Upload iobroker.${name}`); + this.execSync(`${exports.IOBROKER_COMMAND} upload ${name}`, this.profileDir); + } + 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')); + }); + }); + } + 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 (0, utils_1.delay)(5000); + return this.exit(exitCode, 'SIGKILL'); + } + } + process.exit(exitCode); + } + 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); + } + })); + } +} +exports.CommandBase = CommandBase; diff --git a/dist/commands/Debug.js b/dist/commands/Debug.js new file mode 100644 index 00000000..36ea87fb --- /dev/null +++ b/dist/commands/Debug.js @@ -0,0 +1,77 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Debug = void 0; +const chalk_1 = __importDefault(require("chalk")); +const CommandBase_1 = require("./CommandBase"); +const RunCommandBase_1 = require("./RunCommandBase"); +class Debug extends RunCommandBase_1.RunCommandBase { + constructor(owner, wait, noInstall) { + super(owner); + this.wait = wait; + this.noInstall = noInstall; + } + async run() { + 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 startJsControllerDebug() { + this.log.notice(`Starting debugger for ${this.adapterName}`); + const nodeArgs = [ + '--preserve-symlinks', + '--preserve-symlinks-main', + 'node_modules/iobroker.js-controller/controller.js', + ]; + if (this.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 ${this.wait ? 'waiting' : 'available'} on process id ${proc.pid}`); + } + async startAdapterDebug() { + this.log.notice(`Starting ioBroker adapter debugger for ${this.adapterName}.0`); + const args = [ + '--preserve-symlinks', + '--preserve-symlinks-main', + CommandBase_1.IOBROKER_CLI, + 'debug', + `${this.adapterName}.0`, + ]; + if (this.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 ${this.wait ? 'waiting' : 'available'} on process id ${debugPid}`); + } +} +exports.Debug = Debug; diff --git a/dist/commands/Run.js b/dist/commands/Run.js new file mode 100644 index 00000000..79861ae1 --- /dev/null +++ b/dist/commands/Run.js @@ -0,0 +1,15 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Run = void 0; +const RunCommandBase_1 = require("./RunCommandBase"); +class Run extends RunCommandBase_1.RunCommandBase { + constructor(owner, useBrowserSync) { + super(owner); + this.useBrowserSync = useBrowserSync; + } + async run() { + await this.startJsController(); + await this.startServer(this.useBrowserSync); + } +} +exports.Run = Run; diff --git a/dist/commands/RunCommandBase.js b/dist/commands/RunCommandBase.js new file mode 100644 index 00000000..75e775a8 --- /dev/null +++ b/dist/commands/RunCommandBase.js @@ -0,0 +1,601 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.RunCommandBase = void 0; +const acorn_1 = __importDefault(require("acorn")); +const axios_1 = __importDefault(require("axios")); +const browser_sync_1 = __importDefault(require("browser-sync")); +const chalk_1 = __importDefault(require("chalk")); +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_events_1 = __importDefault(require("node:events")); +const node_path_1 = __importDefault(require("node:path")); +const source_map_1 = require("source-map"); +const ws_1 = __importDefault(require("ws")); +const jsonConfig_1 = require("../jsonConfig"); +const CommandBase_1 = require("./CommandBase"); +const utils_1 = require("./utils"); +class RunCommandBase extends CommandBase_1.CommandBase { + constructor() { + super(...arguments); + this.socketEvents = new node_events_1.default(); + } + 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 waitForJsController() { + if (!(await this.waitForPort(CommandBase_1.OBJECTS_DB_PORT_OFFSET)) || !(await this.waitForPort(CommandBase_1.STATES_DB_PORT_OFFSET))) { + throw new Error(`Couldn't start js-controller`); + } + } + async waitForPort(offset) { + const port = this.getPort(offset); + this.log.debug(`Waiting for port ${port} to be available...`); + let tries = 0; + while (true) { + try { + await (0, utils_1.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 (0, utils_1.delay)(1000); + } + } + } + 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`); + } + await this.waitForPort(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET); + const app = (0, express_1.default)(); + const hiddenAdminPort = this.getPort(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET); + 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}/`); + } + /** + * 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, + }; + } + getJsonConfigPath() { + const jsonConfigPath = node_path_1.default.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 ''; + } + /** + * 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(CommandBase_1.HIDDEN_BROWSER_SYNC_PORT_OFFSET); + const adminUrl = `http://127.0.0.1:${this.getPort(CommandBase_1.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, node_path_1.default.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, node_path_1.default.basename(jsonConfigFile))); + }); + } + // Handle tab file watching if present + if (uiCapabilities.tabType !== 'none' && useBrowserSync && bs) { + if (uiCapabilities.tabType === 'json') { + // Watch JSON tab files + const jsonTabPath = node_path_1.default.resolve(this.rootDir, 'admin/jsonTab.json'); + const jsonTab5Path = node_path_1.default.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 = node_path_1.default.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, + })); + } + } + createJsonConfigProxy(app, config, useBrowserSync = true) { + const jsonConfigFile = this.getJsonConfigPath(); + const adminUrl = `http://127.0.0.1:${this.getPort(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET)}`; + if (useBrowserSync) { + // Use BrowserSync for hot-reload functionality + const browserSyncPort = this.getPort(CommandBase_1.HIDDEN_BROWSER_SYNC_PORT_OFFSET); + const bs = this.startBrowserSync(browserSyncPort, false); + // Setup file watching for jsonConfig changes + this.setupJsonFileWatch(bs, jsonConfigFile, node_path_1.default.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, node_path_1.default.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(CommandBase_1.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(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET)}`, + ws: true, + })); + } + else { + // Serve without BrowserSync - serve admin files directly and proxy the rest + const adminPath = node_path_1.default.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(CommandBase_1.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 ((0, fs_extra_1.existsSync)(node_path_1.default.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; + } + startBrowserSync(port, hasReact) { + this.log.notice('Starting browser-sync'); + const bs = browser_sync_1.default.create(); + const adminPath = node_path_1.default.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: [node_path_1.default.join(adminPath, '**')], + plugins: [ + { + module: 'bs-html-injector', + options: { + files: [node_path_1.default.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 (!(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')], + ])); + } + }); + } + 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, + }); + } + async copySourcemaps() { + const outDir = node_path_1.default.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 = node_path_1.default.join(this.rootDir, js); + const dest = node_path_1.default.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 = node_path_1.default.join(this.rootDir, sourcemap); + const dest = node_path_1.default.join(outDir, sourcemap); + await this.patchSourcemap(src, dest); + })); + } + /** + * 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 = node_path_1.default.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}`); + } + } + /** + * 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 = node_path_1.default.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}`); + } + } + 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(); + } + 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 }); + } +} +exports.RunCommandBase = RunCommandBase; diff --git a/dist/commands/Setup.js b/dist/commands/Setup.js new file mode 100644 index 00000000..31488dc0 --- /dev/null +++ b/dist/commands/Setup.js @@ -0,0 +1,336 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Setup = void 0; +const chalk_1 = __importDefault(require("chalk")); +const enquirer_1 = require("enquirer"); +const fs_extra_1 = require("fs-extra"); +const node_os_1 = require("node:os"); +const node_path_1 = __importDefault(require("node:path")); +const rimraf_1 = require("rimraf"); +const CommandBase_1 = require("./CommandBase"); +const utils_1 = require("./utils"); +class Setup extends CommandBase_1.CommandBase { + 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 run() { + if (this.force) { + this.log.notice(`Deleting ${this.profileDir}`); + await (0, rimraf_1.rimraf)(this.profileDir); + } + if (this.owner.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; + } + this.owner.config = { + adminPort: this.adminPort, + useSymlinks: this.useSymlinks, + }; + await this.buildLocalAdapter(); + this.log.notice(`Setting up in ${this.profileDir}`); + await this.setupDevServer(); + const commands = ['run', 'watch', 'debug']; + this.log.box(`dev-server was successfully 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 setupDevServer() { + // create the data directory + const dataDir = node_path_1.default.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(CommandBase_1.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(CommandBase_1.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)(node_path_1.default.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 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 (0, fs_extra_1.writeJson)(node_path_1.default.join(this.profileDir, 'package.json'), pkg, { spaces: 2 }); + // Tell npm to link the local adapter folder instead of creating a copy + if (this.config.useSymlinks) { + await (0, fs_extra_1.writeFile)(node_path_1.default.join(this.profileDir, '.npmrc'), 'install-links=false', 'utf8'); + } + await this.verifyIgnoreFiles(); + this.log.notice('Installing js-controller and admin...'); + await this.installDependencies(); + if (this.backupFile) { + const fullPath = node_path_1.default.resolve(this.backupFile); + this.log.notice(`Restoring backup from ${fullPath}`); + this.execSync(`${CommandBase_1.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(CommandBase_1.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 { + 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() { + this.execSync('npm install --loglevel error --production', this.profileDir); + } + async verifyIgnoreFiles() { + this.log.notice(`Verifying .npmignore and .gitignore`); + let relative = node_path_1.default.relative(this.rootDir, this.owner.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${(0, utils_1.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 = node_path_1.default.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 ((0, fs_extra_1.existsSync)(node_path_1.default.join(this.rootDir, '.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 + 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(`${CommandBase_1.IOBROKER_COMMAND} add ${name} 0`, this.profileDir); + } + } + /** + * 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'); + } + installRepoAdapter(adapterName) { + this.log.notice(`Install iobroker.${adapterName}`); + this.execSync(`${CommandBase_1.IOBROKER_COMMAND} install ${adapterName}`, this.profileDir); + } +} +exports.Setup = Setup; diff --git a/dist/commands/Update.js b/dist/commands/Update.js new file mode 100644 index 00000000..89d8aae0 --- /dev/null +++ b/dist/commands/Update.js @@ -0,0 +1,23 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Update = void 0; +const CommandBase_1 = require("./CommandBase"); +class Update extends CommandBase_1.CommandBase { + async run() { + var _a; + 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 successfully updated.`); + } +} +exports.Update = Update; diff --git a/dist/commands/Upload.js b/dist/commands/Upload.js new file mode 100644 index 00000000..457f1f81 --- /dev/null +++ b/dist/commands/Upload.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Upload = void 0; +const CommandBase_1 = require("./CommandBase"); +class Upload extends CommandBase_1.CommandBase { + async run() { + await this.buildLocalAdapter(); + await this.installLocalAdapter(); + if (!this.isJSController()) { + this.uploadAdapter(this.adapterName); + } + } +} +exports.Upload = Upload; diff --git a/dist/commands/Watch.js b/dist/commands/Watch.js new file mode 100644 index 00000000..b3f5cbdd --- /dev/null +++ b/dist/commands/Watch.js @@ -0,0 +1,244 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Watch = void 0; +const chokidar_1 = __importDefault(require("chokidar")); +const fast_glob_1 = __importDefault(require("fast-glob")); +const fs_extra_1 = require("fs-extra"); +const node_path_1 = __importDefault(require("node:path")); +const nodemon_1 = __importDefault(require("nodemon")); +const RunCommandBase_1 = require("./RunCommandBase"); +const utils_1 = require("./utils"); +class Watch extends RunCommandBase_1.RunCommandBase { + constructor(owner, startAdapter, noInstall, doNotWatch, useBrowserSync) { + super(owner); + this.startAdapter = startAdapter; + this.noInstall = noInstall; + this.doNotWatch = doNotWatch; + this.useBrowserSync = useBrowserSync; + } + async run() { + 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() { + 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: ${this.startAdapter}`); + // use TSC + await this.startTscWatch(); + } + const isTypeScriptMain = this.isTypeScriptMain(pkg.main); + const mainFileSuffix = pkg.main.split('.').pop(); + // start sync + const adapterRunDir = node_path_1.default.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 (this.startAdapter) { + await (0, utils_1.delay)(3000); + this.log.notice('Starting Nodemon'); + await this.startNodemon(adapterRunDir, pkg.main, this.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) => node_path_1.default.join(this.rootDir, filename); + const inDest = (filename) => node_path_1.default.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 = node_path_1.default.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 = [ + node_path_1.default.join(baseDir, 'admin'), + // avoid recursively following symlinks + node_path_1.default.join(baseDir, '.dev-server'), + ]; + if (doNotWatch.length > 0) { + doNotWatch.forEach(entry => ignoreList.push(node_path_1.default.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}`); + } +} +exports.Watch = Watch; diff --git a/dist/commands/utils.js b/dist/commands/utils.js new file mode 100644 index 00000000..445d5d77 --- /dev/null +++ b/dist/commands/utils.js @@ -0,0 +1,30 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.escapeStringRegexp = escapeStringRegexp; +exports.delay = delay; +exports.checkPort = checkPort; +const node_net_1 = require("node:net"); +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'); +} +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} +function 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(); + }); + }); +} diff --git a/dist/index.js b/dist/index.js index ad00f72f..f602d43d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,1804 +1,4 @@ -#!/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 dbConnection_1 = require("@iobroker/testing/build/tests/integration/lib/dbConnection"); -const acorn_1 = __importDefault(require("acorn")); -const axios_1 = __importDefault(require("axios")); -const browser_sync_1 = __importDefault(require("browser-sync")); -const chalk_1 = __importDefault(require("chalk")); -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 cp = __importStar(require("node:child_process")); -const node_events_1 = __importDefault(require("node:events")); -const node_net_1 = require("node:net"); -const node_os_1 = require("node:os"); -const path = __importStar(require("node:path")); -const nodemon_1 = __importDefault(require("nodemon")); -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 yargs_1 = __importDefault(require("yargs/yargs")); -const jsonConfig_1 = require("./jsonConfig"); -const logger_1 = require("./logger"); -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 successfully 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); - } -} -(() => new DevServer())(); +const DevServer_1 = require("./DevServer"); +(() => new DevServer_1.DevServer())(); diff --git a/src/DevServer.ts b/src/DevServer.ts new file mode 100644 index 00000000..34f42aea --- /dev/null +++ b/src/DevServer.ts @@ -0,0 +1,479 @@ +#!/usr/bin/env node + +import axios from 'axios'; +import chalk from 'chalk'; +import { prompt } from 'enquirer'; +import { existsSync, mkdir, readdir, readJson, rename } from 'fs-extra'; +import * as path from 'node:path'; +import { gt } from 'semver'; +import yargs from 'yargs/yargs'; +import { Backup } from './commands/Backup'; +import { Debug } from './commands/Debug'; +import { Run } from './commands/Run'; +import { Setup } from './commands/Setup'; +import { Update } from './commands/Update'; +import { Upload } from './commands/Upload'; +import { Watch } from './commands/Watch'; +import { Logger } from './logger'; + +const DEFAULT_TEMP_DIR_NAME = '.dev-server'; +const CORE_MODULE = 'iobroker.js-controller'; + +const DEFAULT_ADMIN_PORT = 8081; +const DEFAULT_PROFILE_NAME = 'default'; + +export interface RemoteConfig { + id: string; + host: string; + port: number; + user: string; + privateKeyPath?: string; +} + +export interface DevServerConfig { + adminPort: number; + useSymlinks: boolean; + remote?: RemoteConfig; +} + +export type CoreDependency = 'iobroker.js-controller' | 'iobroker.admin'; +export type DependencyVersions = Partial>; + +export class DevServer { + public log!: Logger; + public rootDir!: string; + public adapterName!: string; + public tempDir!: string; + public profileName!: string; + public profileDir!: string; + public config?: DevServerConfig; + + 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', + alias: 'r', + description: 'Run ioBroker and the adapter on a remote host', + }, + 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 as string), + ) + .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; + } + + private setLogger(argv: { verbose: boolean }): Promise { + this.log = new Logger(argv.verbose ? 'silly' : 'debug'); + return Promise.resolve(); + } + + private async checkVersion(): Promise { + 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 prompt<{ update: boolean }>({ + 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 + } + } + + public async readMyPackageJson(): Promise { + return readJson(path.join(__dirname, '..', 'package.json')); + } + + private async setDirectories(argv: { + _: (string | number)[]; + root: string; + temp: string; + profile?: string; + }): Promise { + this.rootDir = path.resolve(argv.root); + this.tempDir = path.resolve(this.rootDir, argv.temp); + if (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 rename(this.tempDir, intermediateDir); + await mkdir(this.tempDir); + 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 prompt<{ profile: string }>({ + 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.profileDir = path.join(this.tempDir, profileName); + this.adapterName = await this.findAdapterName(); + } + + private async parseConfig(): Promise { + let pkg: Record; + try { + pkg = await readJson(path.join(this.profileDir, 'package.json')); + } catch { + // not all commands need the config + return; + } + + this.config = pkg['dev-server']; + } + + private async findAdapterName(): Promise { + try { + const ioPackage = await readJson(path.join(this.rootDir, 'io-package.json')); + const adapterName = ioPackage.common.name; + this.log.debug(`Using adapter name "${adapterName}"`); + return adapterName; + } catch (error: any) { + 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: number, + dependencies: DependencyVersions, + backupFile: string | undefined, + remote: boolean, + force: boolean, + useSymlinks: boolean, + ): Promise { + const setup = new Setup(this, adminPort, dependencies, backupFile, force, useSymlinks); + await setup.run(); + } + + private async update(): Promise { + await this.checkSetup(); + + const update = new Update(this); + await update.run(); + } + + async run(useBrowserSync = true): Promise { + await this.checkSetup(); + + const run = new Run(this, useBrowserSync); + await run.run(); + } + + async watch( + startAdapter: boolean, + noInstall: boolean, + doNotWatch: string | string[] | undefined, + useBrowserSync = true, + ): Promise { + let doNotWatchArr: string[] = []; + if (typeof doNotWatch === 'string') { + doNotWatchArr.push(doNotWatch); + } else if (Array.isArray(doNotWatch)) { + doNotWatchArr = doNotWatch; + } + + await this.checkSetup(); + + const watch = new Watch(this, noInstall, startAdapter, doNotWatchArr, useBrowserSync); + await watch.run(); + } + + async debug(wait: boolean, noInstall: boolean): Promise { + await this.checkSetup(); + + const debug = new Debug(this, wait, noInstall); + await debug.run(); + } + + async upload(): Promise { + await this.checkSetup(); + + const upload = new Upload(this); + await upload.run(); + + this.log.box(`The latest content of iobroker.${this.adapterName} was uploaded to ${this.profileDir}.`); + } + + async backup(filename: string): Promise { + await this.checkSetup(); + + this.log.notice('Creating backup'); + + const fullPath = path.resolve(filename); + const backup = new Backup(this, fullPath); + await backup.run(); + } + + async profile(): Promise { + 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.bold('Profile Name'), + chalk.bold('Admin URL'), + chalk.bold('js-controller'), + chalk.bold('admin'), + ]); + this.log.info(`The following profiles exist in ${this.tempDir}`); + this.log.table(table.filter(r => !!r) as any); + } + + ////////////////// Command Helper Methods ////////////////// + + async getProfiles(): Promise> { + if (!existsSync(this.tempDir)) { + return {}; + } + + const entries = await readdir(this.tempDir); + const pkgs = await Promise.all( + entries.map(async e => { + try { + const pkg = await readJson(path.join(this.tempDir, 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) as any[]).reduce>( + (old, [e, pkg]) => ({ ...old, [e]: pkg }), + {}, + ); + } + + async checkSetup(): Promise { + 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 process.exit(-1); + } + } + + public isSetUp(): boolean { + const jsControllerDir = path.join(this.profileDir, 'node_modules', CORE_MODULE); + return existsSync(jsControllerDir); + } +} + +(() => new DevServer())(); diff --git a/src/commands/Backup.ts b/src/commands/Backup.ts new file mode 100644 index 00000000..3cc4afd2 --- /dev/null +++ b/src/commands/Backup.ts @@ -0,0 +1,15 @@ +import type { DevServer } from '../DevServer'; +import { CommandBase, IOBROKER_COMMAND } from './CommandBase'; + +export class Backup extends CommandBase { + constructor( + owner: DevServer, + private readonly filename: string, + ) { + super(owner); + } + + public async run(): Promise { + this.execSync(`${IOBROKER_COMMAND} backup "${this.filename}"`, this.profileDir); + } +} diff --git a/src/commands/CommandBase.ts b/src/commands/CommandBase.ts new file mode 100644 index 00000000..d2602590 --- /dev/null +++ b/src/commands/CommandBase.ts @@ -0,0 +1,291 @@ +import { DBConnection } from '@iobroker/testing/build/tests/integration/lib/dbConnection'; +import { readJson } from 'fs-extra'; +import * as cp from 'node:child_process'; +import path from 'node:path'; +import psTree from 'ps-tree'; +import { rimraf } from 'rimraf'; +import type { DevServer } from '../DevServer'; +import { delay } from './utils'; + +export const IOBROKER_CLI = 'node_modules/iobroker.js-controller/iobroker.js'; +export const IOBROKER_COMMAND = `node ${IOBROKER_CLI}`; + +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 abstract class CommandBase { + protected readonly childProcesses: cp.ChildProcess[] = []; + + constructor(protected readonly owner: DevServer) {} + + protected get log() { + return this.owner.log; + } + + protected get rootDir(): string { + return this.owner.rootDir; + } + + protected get profileDir(): string { + return this.owner.profileDir; + } + + protected get profileName(): string { + return this.owner.profileName; + } + + protected get adapterName(): string { + return this.owner.adapterName; + } + + protected get config() { + if (!this.owner.config) { + throw new Error('DevServer is not configured yet'); + } + + return this.owner.config; + } + + public abstract run(): Promise; + + protected getPort(offset: number): number { + return this.config.adminPort + offset; + } + + protected isJSController(): boolean { + return this.adapterName === 'js-controller'; + } + + protected readPackageJson(): Promise { + return 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 + */ + protected async readIoPackageJson(): Promise { + return readJson(path.join(this.rootDir, 'io-package.json')); + } + + protected isTypeScriptMain(mainFile: string): boolean { + return !!(mainFile && mainFile.endsWith('.ts')); + } + + protected async installLocalAdapter(doInstall = true): Promise { + this.log.notice(`Install local iobroker.${this.adapterName}`); + + if (this.config.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 readJson(path.join(this.profileDir, 'package.json')); + const depPath = tempPkg.dependencies?.[`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 rimraf(fullPath); + } + } + } + + protected async buildLocalAdapter(): Promise { + const pkg = await this.readPackageJson(); + if (pkg.scripts?.build) { + this.log.notice(`Build iobroker.${this.adapterName}`); + this.execSync('npm run build', this.rootDir); + } + } + + protected uploadAdapter(name: string): void { + this.log.notice(`Upload iobroker.${name}`); + this.execSync(`${IOBROKER_COMMAND} upload ${name}`, this.profileDir); + } + + protected async withDb(method: (db: DBConnection) => Promise): Promise { + const db = new DBConnection('iobroker', this.profileDir, this.log); + await db.start(); + try { + return await method(db); + } finally { + await db.stop(); + } + } + + protected async updateObject( + id: T, + method: (obj: ioBroker.ObjectIdToObjectType) => ioBroker.SettableObject>, + ): Promise { + await this.withDb(async db => { + const obj = await db.getObject(id); + if (obj) { + // @ts-expect-error fix later + await db.setObject(id, method(obj)); + } + }); + } + + protected execSync(command: string, cwd: string, options?: cp.ExecSyncOptionsWithBufferEncoding): Buffer { + options = { cwd: cwd, stdio: 'inherit', ...options }; + this.log.debug(`${cwd}> ${command}`); + return cp.execSync(command, options); + } + + protected getExecOutput(command: string, cwd: string): Promise<{ stdout: string; stderr: string }> { + this.log.debug(`${cwd}> ${command}`); + return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + this.childProcesses.push( + cp.exec(command, { cwd, encoding: 'ascii' }, (err, stdout, stderr) => { + if (err) { + reject(err); + } else { + resolve({ stdout, stderr }); + } + }), + ); + }); + } + + protected spawn( + command: string, + args: ReadonlyArray, + cwd: string, + options?: cp.SpawnOptions, + ): Promise { + 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')); + }); + } + + protected async spawnAndAwaitOutput( + command: string, + args: ReadonlyArray, + cwd: string, + awaitMsg: string | RegExp, + options?: cp.SpawnOptions, + ): Promise { + const proc = await this.spawn(command, args, cwd, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); + return new Promise((resolve, reject) => { + const handleStream = (isStderr: boolean) => (data: Buffer) => { + 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')); + }); + }); + } + + protected async exit(exitCode: number, signal = 'SIGINT'): Promise { + const childPids = this.childProcesses.map(p => p.pid).filter(p => !!p) as number[]; + const tryKill = (pid: number, signal: string): void => { + try { + process.kill(pid, signal); + } catch { + // 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 as 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.exit(exitCode, 'SIGKILL'); + } + } + process.exit(exitCode); + } + + protected async waitForNodeChildProcess(parentPid: number): Promise { + 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; + } + + private getChildProcesses(parentPid: number): Promise { + return new Promise((resolve, reject) => + psTree(parentPid, (err, children) => { + if (err) { + reject(err); + } else { + // fix for MacOS bug #11 + children.forEach((c: any) => { + if (c.COMM && !c.COMMAND) { + c.COMMAND = c.COMM; + } + }); + resolve(children); + } + }), + ); + } +} diff --git a/src/commands/Debug.ts b/src/commands/Debug.ts new file mode 100644 index 00000000..45142084 --- /dev/null +++ b/src/commands/Debug.ts @@ -0,0 +1,81 @@ +import chalk from 'chalk'; +import type { DevServer } from '../DevServer'; +import { IOBROKER_CLI } from './CommandBase'; +import { RunCommandBase } from './RunCommandBase'; + +export class Debug extends RunCommandBase { + constructor( + owner: DevServer, + private readonly wait: boolean, + private readonly noInstall: boolean, + ) { + super(owner); + } + + public async run(): Promise { + 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(); + } + } + + private async startJsControllerDebug(): Promise { + this.log.notice(`Starting debugger for ${this.adapterName}`); + + const nodeArgs = [ + '--preserve-symlinks', + '--preserve-symlinks-main', + 'node_modules/iobroker.js-controller/controller.js', + ]; + if (this.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.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 ${proc.pid}`); + } + + private async startAdapterDebug(): Promise { + 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.wait) { + args.push('--wait'); + } + const proc = await this.spawn('node', args, this.profileDir); + proc.on('exit', code => { + console.error(chalk.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 ${this.wait ? 'waiting' : 'available'} on process id ${debugPid}`); + } +} diff --git a/src/commands/Run.ts b/src/commands/Run.ts new file mode 100644 index 00000000..666f076c --- /dev/null +++ b/src/commands/Run.ts @@ -0,0 +1,16 @@ +import type { DevServer } from '../DevServer'; +import { RunCommandBase } from './RunCommandBase'; + +export class Run extends RunCommandBase { + constructor( + owner: DevServer, + private readonly useBrowserSync: boolean, + ) { + super(owner); + } + + public async run(): Promise { + await this.startJsController(); + await this.startServer(this.useBrowserSync); + } +} diff --git a/src/commands/RunCommandBase.ts b/src/commands/RunCommandBase.ts new file mode 100644 index 00000000..53180334 --- /dev/null +++ b/src/commands/RunCommandBase.ts @@ -0,0 +1,710 @@ +import acorn from 'acorn'; +import axios from 'axios'; +import browserSync from 'browser-sync'; +import chalk from 'chalk'; +import express, { type Application } from 'express'; +import fg from 'fast-glob'; +import { existsSync, readFile, readJson, writeFile, writeJson } from 'fs-extra'; +import { legacyCreateProxyMiddleware as createProxyMiddleware } from 'http-proxy-middleware'; +import EventEmitter from 'node:events'; +import path from 'node:path'; +import { RawSourceMap, SourceMapGenerator } from 'source-map'; +import WebSocket from 'ws'; +import type { DevServerConfig } from '../DevServer'; +import { injectCode } from '../jsonConfig'; +import { + CommandBase, + HIDDEN_ADMIN_PORT_OFFSET, + HIDDEN_BROWSER_SYNC_PORT_OFFSET, + OBJECTS_DB_PORT_OFFSET, + STATES_DB_PORT_OFFSET, +} from './CommandBase'; +import { checkPort, delay } from './utils'; + +export abstract class RunCommandBase extends CommandBase { + private websocket?: WebSocket; + + protected readonly socketEvents = new EventEmitter(); + + protected async startJsController(): Promise { + 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.yellow(`ioBroker controller exited with code ${code}`)); + return this.exit(-1, 'SIGKILL'); + }); + this.log.notice('Waiting for js-controller to start...'); + await this.waitForJsController(); + } + + protected async waitForJsController(): Promise { + if (!(await this.waitForPort(OBJECTS_DB_PORT_OFFSET)) || !(await this.waitForPort(STATES_DB_PORT_OFFSET))) { + throw new Error(`Couldn't start js-controller`); + } + } + + private async waitForPort(offset: number): Promise { + const port = this.getPort(offset); + this.log.debug(`Waiting for port ${port} to be available...`); + let tries = 0; + while (true) { + try { + await checkPort(port); + this.log.debug(`Port ${port} is available...`); + return true; + } catch { + if (tries++ > 30) { + this.log.error(`Port ${port} is not available after 30 seconds.`); + return false; + } + await delay(1000); + } + } + } + + protected async startServer(useBrowserSync = true): Promise { + this.log.notice(`Running inside ${this.profileDir}`); + + if (!this.config) { + throw new Error(`Couldn't find dev-server configuration in package.json`); + } + + await this.waitForPort(HIDDEN_ADMIN_PORT_OFFSET); + + 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, 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', (): void => { + 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 = (): void => { + 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 as 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' + */ + private async getAdapterUiCapabilities(): Promise<{ + configType: 'json' | 'html' | 'none'; + tabType: 'json' | 'html' | 'none'; + }> { + let configType: 'json' | 'html' | 'none' = 'none'; + let tabType: 'json' | 'html' | 'none' = '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 as Error}`); + } + } + + this.log.debug(`UI capabilities: configType=${configType}, tabType=${tabType}`); + + return { + configType, + tabType, + }; + } + + private getJsonConfigPath(): string { + const jsonConfigPath = path.resolve(this.rootDir, '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 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) + */ + private async createCombinedConfigProxy( + app: Application, + config: DevServerConfig, + uiCapabilities: { + configType: 'json' | 'html' | 'none'; + tabType: 'json' | 'html' | 'none'; + }, + useBrowserSync = true, + ): Promise { + // This method combines the functionality of createJsonConfigProxy and createHtmlConfigProxy + // to support adapters that use jsonConfig and tabs simultaneously + + const pathRewrite: Record = {}; + 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: any = 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.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 (existsSync(tabHtmlPath)) { + bs.watch(tabHtmlPath, undefined, (e: any) => { + 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, + }), + ); + } + } + + private createJsonConfigProxy(app: Application, config: DevServerConfig, useBrowserSync = true): Promise { + 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(); + } + + private async createHtmlConfigProxy( + app: Application, + config: DevServerConfig, + useBrowserSync = true, + ): Promise { + const pathRewrite: Record = {}; + 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.rootDir, '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 + */ + private async setupReactWatch(pathRewrite: Record): Promise { + 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.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; + } + + private startBrowserSync(port: number, hasReact: boolean): browserSync.BrowserSyncInstance { + this.log.notice('Starting browser-sync'); + const bs = browserSync.create(); + + const adminPath = path.resolve(this.rootDir, 'admin/'); + const config: browserSync.Options = { + 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 + */ + private setupJsonFileWatch(bs: any, filePath: string, fileName: string): void { + if (!existsSync(filePath)) { + return; + } + + bs.watch(filePath, undefined, async (e: any) => { + 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')], + ]), + ); + } + }); + } + + private async startReact(scriptName: string): Promise { + 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, + }, + ); + } + + protected async copySourcemaps(): Promise { + 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); + }), + ); + } + + /** + * 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. + */ + protected async patchSourcemap(src: string, dest: string): Promise { + 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 writeJson(dest, data); + this.log.debug(`Patched ${dest} from ${src}`); + } catch (error) { + this.log.warn(`Couldn't patch ${dest}: ${error as Error}`); + } + } + + /** + * 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). + */ + protected async addSourcemap(src: string, dest: string, copyFromSrc: boolean): Promise { + try { + const mapFile = `${dest}.map`; + const data = await this.createIdentitySourcemap(src.replace(/\\/g, '/')); + await writeFile(mapFile, JSON.stringify(data)); + + // append the sourcemap reference comment to the bottom of the file + const fileContent = await 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 writeFile(dest, updatedContent); + this.log.debug(`Created ${mapFile} from ${src}`); + } catch (error) { + this.log.warn(`Couldn't reverse map for ${src}: ${error as Error}`); + } + } + + private async createIdentitySourcemap(filename: string): Promise { + // 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 tokenizer = acorn.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(); + } + + protected getFilePatterns(extensions: string | string[], excludeAdmin: boolean): string[] { + 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; + } + + private async findFiles(extension: string, excludeAdmin: boolean): Promise { + return await fg(this.getFilePatterns(extension, excludeAdmin), { cwd: this.rootDir }); + } +} diff --git a/src/commands/Setup.ts b/src/commands/Setup.ts new file mode 100644 index 00000000..60e44725 --- /dev/null +++ b/src/commands/Setup.ts @@ -0,0 +1,372 @@ +import chalk from 'chalk'; +import { prompt } from 'enquirer'; +import { existsSync, mkdirp, readFile, writeFile, writeJson } from 'fs-extra'; +import { EOL, hostname } from 'node:os'; +import path from 'node:path'; +import { rimraf } from 'rimraf'; +import type { DependencyVersions, DevServer } from '../DevServer'; +import { + CommandBase, + HIDDEN_ADMIN_PORT_OFFSET, + IOBROKER_COMMAND, + OBJECTS_DB_PORT_OFFSET, + STATES_DB_PORT_OFFSET, +} from './CommandBase'; +import { escapeStringRegexp } from './utils'; + +export class Setup extends CommandBase { + constructor( + owner: DevServer, + private readonly adminPort: number, + protected readonly dependencies: DependencyVersions, + private readonly backupFile: string | undefined, + private readonly force: boolean, + private readonly useSymlinks: boolean, + ) { + super(owner); + } + + public async run(): Promise { + if (this.force) { + this.log.notice(`Deleting ${this.profileDir}`); + await rimraf(this.profileDir); + } + + if (this.owner.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; + } + + this.owner.config = { + adminPort: this.adminPort, + useSymlinks: this.useSymlinks, + }; + + await this.buildLocalAdapter(); + + this.log.notice(`Setting up in ${this.profileDir}`); + await this.setupDevServer(); + + const commands = ['run', 'watch', 'debug']; + this.log.box( + `dev-server was successfully 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.`, + ); + } + + protected async setupDevServer(): Promise { + // create the data directory + const dataDir = path.join(this.profileDir, 'iobroker-data'); + await mkdirp(dataDir); + + // 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, { 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 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.profileDir, 'package.json'), pkg, { spaces: 2 }); + + // Tell npm to link the local adapter folder instead of creating a copy + if (this.config.useSymlinks) { + await writeFile(path.join(this.profileDir, '.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}`); + 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(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 { + this.installRepoAdapter(adapter); + } catch (error) { + this.log.debug(`Couldn't install iobroker.${adapter}: ${error as 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; + }); + } + + protected async installDependencies(): Promise { + this.execSync('npm install --loglevel error --production', this.profileDir); + } + + private async verifyIgnoreFiles(): Promise { + this.log.notice(`Verifying .npmignore and .gitignore`); + let relative = path.relative(this.rootDir, this.owner.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${escapeStringRegexp(relative) + .replace(/[\\/]$/, '') + .replace(/(\\\\|\/)/g, '[\\/]')}`, + ); + const verifyFile = async (fileName: string, command: string, allowStar: boolean): Promise => { + try { + const { stdout, stderr } = await this.getExecOutput(command, this.rootDir); + 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', + }, + ); + type Action = 'add-star' | 'add-explicit' | 'abort'; + let action: Action; + try { + const result = await prompt<{ action: Action }>({ + 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.rootDir, 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 as Error}`); + } + }; + await verifyFile('.npmignore', 'npm pack --dry-run', true); + + // Only verify .gitignore if we're in a git repository + if (existsSync(path.join(this.rootDir, '.git'))) { + await verifyFile('.gitignore', 'git status --short --untracked-files=all', false); + } else { + this.log.debug('Skipping .gitignore verification: not in a git repository'); + } + } + + private async uploadAndAddAdapter(name: string): Promise { + // 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); + } + } + + /** + * 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. + */ + private getDependencies(dependencies: any): string[] { + const adapters: string[] = []; + 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'); + } + + private installRepoAdapter(adapterName: string): void { + this.log.notice(`Install iobroker.${adapterName}`); + this.execSync(`${IOBROKER_COMMAND} install ${adapterName}`, this.profileDir); + } +} diff --git a/src/commands/Update.ts b/src/commands/Update.ts new file mode 100644 index 00000000..7a58425c --- /dev/null +++ b/src/commands/Update.ts @@ -0,0 +1,23 @@ +import { CommandBase } from './CommandBase'; + +export class Update extends CommandBase { + public async run(): Promise { + 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. + } + + 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 successfully updated.`); + } +} diff --git a/src/commands/Upload.ts b/src/commands/Upload.ts new file mode 100644 index 00000000..f81d56ea --- /dev/null +++ b/src/commands/Upload.ts @@ -0,0 +1,12 @@ +import { CommandBase } from './CommandBase'; + +export class Upload extends CommandBase { + public async run(): Promise { + await this.buildLocalAdapter(); + await this.installLocalAdapter(); + + if (!this.isJSController()) { + this.uploadAdapter(this.adapterName); + } + } +} diff --git a/src/commands/Watch.ts b/src/commands/Watch.ts new file mode 100644 index 00000000..3b73b270 --- /dev/null +++ b/src/commands/Watch.ts @@ -0,0 +1,258 @@ +import chokidar from 'chokidar'; +import fg from 'fast-glob'; +import { copyFile, existsSync, unlinkSync } from 'fs-extra'; +import path from 'node:path'; +import nodemon from 'nodemon'; +import type { DevServer } from '../DevServer'; +import { RunCommandBase } from './RunCommandBase'; +import { delay } from './utils'; + +export class Watch extends RunCommandBase { + constructor( + owner: DevServer, + private readonly startAdapter: boolean, + private readonly noInstall: boolean, + private readonly doNotWatch: string[], + private readonly useBrowserSync: boolean, + ) { + super(owner); + } + + public async run(): Promise { + 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(); + } + } + + private async startAdapterWatch(): Promise { + // 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(this.profileDir, '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); + this.log.notice('Starting Nodemon'); + await this.startNodemon(adapterRunDir, pkg.main, this.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 + }`, + ); + } + } + + private async startTscWatch(): Promise { + 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, + }); + } + + private startFileSync(destinationDir: string, mainFileSuffix: string): Promise { + this.log.notice(`Starting file system sync from ${this.rootDir}`); + const inSrc = (filename: string): string => path.join(this.rootDir, filename); + const inDest = (filename: string): string => 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 = [] as string[]; + const watcher = chokidar.watch(fg.sync(patterns), { cwd: this.rootDir }); + let ready = false; + let initialEventPromises: Promise[] = []; + 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: string): Promise => { + 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 copyFile(src, dest); + } + } catch { + this.log.warn(`Couldn't sync ${filename}`); + } + }; + watcher.on('add', (filename: string) => { + if (ready) { + void syncFile(filename); + } else if (!filename.endsWith('map') && !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: string) => { + if (!ignoreFiles.includes(filename)) { + const resPromise = syncFile(filename); + if (!ready) { + initialEventPromises.push(resPromise); + } + } + }); + watcher.on('unlink', (filename: string) => { + unlinkSync(inDest(filename)); + const map = inDest(`${filename}.map`); + if (existsSync(map)) { + unlinkSync(map); + } + }); + }); + } + + private startNodemon(baseDir: string, scriptName: string, doNotWatch: string[]): Promise { + 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: Record = { + 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', + }; + + nodemon({ + 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' as any, // wrong type definition: signal is of type "string?" + args, + }); + + nodemon + .on('log', (msg: { type: 'log' | 'info' | 'status' | 'detail' | 'fail' | 'error'; message: string }) => { + 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: any) => { + 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(); + } + + async handleNodemonDetailMsg(message: string): Promise { + 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/src/commands/utils.ts b/src/commands/utils.ts new file mode 100644 index 00000000..93309e33 --- /dev/null +++ b/src/commands/utils.ts @@ -0,0 +1,31 @@ +import { Socket } from 'node:net'; + +export function escapeStringRegexp(value: string): string { + // 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 function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function checkPort(port: number, host = '127.0.0.1', timeout = 1000): Promise { + return new Promise((resolve, reject) => { + const socket = new Socket(); + + const onError = (error: string): void => { + socket.destroy(); + reject(new Error(error)); + }; + + socket.setTimeout(timeout); + socket.once('error', onError); + socket.once('timeout', onError); + + socket.connect(port, host, () => { + socket.end(); + resolve(); + }); + }); +} diff --git a/src/index.ts b/src/index.ts index b03053e3..6977af00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2107 +1,3 @@ -#!/usr/bin/env node - -import { DBConnection } from '@iobroker/testing/build/tests/integration/lib/dbConnection'; -import acorn from 'acorn'; -import axios from 'axios'; -import browserSync from 'browser-sync'; -import chalk from 'chalk'; -import chokidar from 'chokidar'; -import { prompt } from 'enquirer'; -import express, { type Application } from 'express'; -import fg from 'fast-glob'; -import { - copyFile, - existsSync, - mkdir, - mkdirp, - readFile, - readFileSync, - readJson, - readdir, - rename, - unlinkSync, - writeFile, - writeJson, -} from 'fs-extra'; -import { legacyCreateProxyMiddleware as createProxyMiddleware } from 'http-proxy-middleware'; -import * as cp from 'node:child_process'; -import EventEmitter from 'node:events'; -import { Socket } from 'node:net'; -import { EOL, hostname } from 'node:os'; -import * as path from 'node:path'; -import nodemon from 'nodemon'; -import psTree from 'ps-tree'; -import { rimraf } from 'rimraf'; -import { gt } from 'semver'; -import { type RawSourceMap, SourceMapGenerator } from 'source-map'; -import WebSocket from 'ws'; -import yargs from 'yargs/yargs'; -import { injectCode } from './jsonConfig'; -import { Logger } from './logger'; - -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'; - -interface DevServerConfig { - adminPort: number; - useSymlinks: boolean; -} - -type CoreDependency = 'iobroker.js-controller' | 'iobroker.admin'; -type DependencyVersions = Partial>; - -class DevServer { - private log!: Logger; - private rootDir!: string; - private adapterName!: string; - private tempDir!: string; - private profileName!: string; - private profileDir!: string; - private config?: DevServerConfig; - - private readonly socketEvents = new EventEmitter(); - - private readonly childProcesses: cp.ChildProcess[] = []; - - private websocket?: WebSocket; - - 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', - }, - 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 as string), - ) - .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; - } - - private setLogger(argv: { verbose: boolean }): Promise { - this.log = new Logger(argv.verbose ? 'silly' : 'debug'); - return Promise.resolve(); - } - - private async checkVersion(): Promise { - try { - const { name, version: localVersion } = JSON.parse( - readFileSync(path.join(__dirname, '..', 'package.json')).toString(), - ); - 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 prompt<{ update: boolean }>({ - 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 { - // ignore - } - } - - private async setDirectories(argv: { - _: (string | number)[]; - root: string; - temp: string; - profile?: string; - }): Promise { - this.rootDir = path.resolve(argv.root); - this.tempDir = path.resolve(this.rootDir, argv.temp); - if (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 rename(this.tempDir, intermediateDir); - await mkdir(this.tempDir); - 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 prompt<{ profile: string }>({ - 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.profileDir = path.join(this.tempDir, profileName); - this.adapterName = await this.findAdapterName(); - } - - private async parseConfig(): Promise { - let pkg: Record; - try { - pkg = await readJson(path.join(this.profileDir, 'package.json')); - } catch { - // not all commands need the config - return; - } - - this.config = pkg['dev-server']; - } - - private async findAdapterName(): Promise { - try { - const ioPackage = await readJson(path.join(this.rootDir, 'io-package.json')); - const adapterName = ioPackage.common.name; - this.log.debug(`Using adapter name "${adapterName}"`); - return adapterName; - } catch (error: any) { - 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); - } - } - - private isJSController(): boolean { - return this.adapterName === 'js-controller'; - } - - private readPackageJson(): Promise { - return 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 - */ - private async readIoPackageJson(): Promise { - return 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' - */ - private async getAdapterUiCapabilities(): Promise<{ - configType: 'json' | 'html' | 'none'; - tabType: 'json' | 'html' | 'none'; - }> { - let configType: 'json' | 'html' | 'none' = 'none'; - let tabType: 'json' | 'html' | 'none' = '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 as Error}`); - } - } - - this.log.debug(`UI capabilities: configType=${configType}, tabType=${tabType}`); - - return { - configType, - tabType, - }; - } - - private isTypeScriptMain(mainFile: string): boolean { - return !!(mainFile && mainFile.endsWith('.ts')); - } - - private getPort(adminPort: number, offset: number): number { - let port = adminPort + offset; - if (port > 65000) { - port -= 63000; - } - return port; - } - - private getJsonConfigPath(): string { - const jsonConfigPath = path.resolve(this.rootDir, 'admin/jsonConfig.json'); - if (existsSync(jsonConfigPath)) { - return jsonConfigPath; - } - if (existsSync(`${jsonConfigPath}5`)) { - return `${jsonConfigPath}5`; - } - return ''; - } - - ////////////////// Command Handlers ////////////////// - - async setup( - adminPort: number, - dependencies: DependencyVersions, - backupFile?: string, - force?: boolean, - useSymlinks = false, - ): Promise { - if (force) { - this.log.notice(`Deleting ${this.profileDir}`); - await 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.`, - ); - } - - private async update(): Promise { - await this.checkSetup(); - 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. - } - 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 successfully updated.`); - } - - async run(useBrowserSync = true): Promise { - await this.checkSetup(); - await this.startJsController(); - await this.startServer(useBrowserSync); - } - - async watch( - startAdapter: boolean, - noInstall: boolean, - doNotWatch: string | string[] | undefined, - useBrowserSync = true, - ): Promise { - let doNotWatchArr: string[] = []; - 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: boolean, noInstall: boolean): Promise { - 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(): Promise { - 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: string): Promise { - const fullPath = path.resolve(filename); - this.log.notice('Creating backup'); - this.execSync(`${IOBROKER_COMMAND} backup "${fullPath}"`, this.profileDir); - return Promise.resolve(); - } - - async profile(): Promise { - 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.bold('Profile Name'), - chalk.bold('Admin URL'), - chalk.bold('js-controller'), - chalk.bold('admin'), - ]); - this.log.info(`The following profiles exist in ${this.tempDir}`); - this.log.table(table.filter(r => !!r) as any); - } - - ////////////////// Command Helper Methods ////////////////// - - async getProfiles(): Promise> { - if (!existsSync(this.tempDir)) { - return {}; - } - - const entries = await readdir(this.tempDir); - const pkgs = await Promise.all( - entries.map(async e => { - try { - const pkg = await readJson(path.join(this.tempDir, 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) as any[]).reduce>( - (old, [e, pkg]) => ({ ...old, [e]: pkg }), - {}, - ); - } - - async checkSetup(): Promise { - 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(): boolean { - const jsControllerDir = path.join(this.profileDir, 'node_modules', CORE_MODULE); - return existsSync(jsControllerDir); - } - - checkPort(port: number, host = '127.0.0.1', timeout = 1000): Promise { - return new Promise((resolve, reject) => { - const socket = new Socket(); - - const onError = (error: string): void => { - 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: number, offset = 0): Promise { - 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 { - if (tries++ > 30) { - this.log.error(`Port ${port} is not available after 30 seconds.`); - return false; - } - await this.delay(1000); - } - } - } - - async waitForJsController(): Promise { - 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(): Promise { - 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.yellow(`ioBroker controller exited with code ${code}`)); - return this.exit(-1, 'SIGKILL'); - }); - this.log.notice('Waiting for js-controller to start...'); - await this.waitForJsController(); - } - - private async startJsControllerDebug(wait: boolean): Promise { - 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.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: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - async startServer(useBrowserSync = true): Promise { - 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 = express(); - 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, 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', (): void => { - 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 = (): void => { - 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 as 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 - */ - private setupJsonFileWatch(bs: any, filePath: string, fileName: string): void { - if (!existsSync(filePath)) { - return; - } - - bs.watch(filePath, undefined, async (e: any) => { - 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')], - ]), - ); - } - }); - } - - /** - * Helper method to setup React build watching - * Returns true if React watching was started, false otherwise - */ - private async setupReactWatch(pathRewrite: Record): Promise { - 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.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; - } - - private createJsonConfigProxy(app: Application, config: DevServerConfig, useBrowserSync = true): Promise { - 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.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(); - } - - private async createHtmlConfigProxy( - app: Application, - config: DevServerConfig, - useBrowserSync = true, - ): Promise { - const pathRewrite: Record = {}; - 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( - 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(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.static(adminPath)); - - // admin proxy for everything else - app.use( - createProxyMiddleware([`!${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) - */ - private async createCombinedConfigProxy( - app: Application, - config: DevServerConfig, - uiCapabilities: { - configType: 'json' | 'html' | 'none'; - tabType: 'json' | 'html' | 'none'; - }, - useBrowserSync = true, - ): Promise { - // This method combines the functionality of createJsonConfigProxy and createHtmlConfigProxy - // to support adapters that use jsonConfig and tabs simultaneously - - const pathRewrite: Record = {}; - 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: any = 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.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 (existsSync(tabHtmlPath)) { - bs.watch(tabHtmlPath, undefined, (e: any) => { - 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, - }), - ); - } - } - - private async copySourcemaps(): Promise { - 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). - */ - private async addSourcemap(src: string, dest: string, copyFromSrc: boolean): Promise { - try { - const mapFile = `${dest}.map`; - const data = await this.createIdentitySourcemap(src.replace(/\\/g, '/')); - await writeFile(mapFile, JSON.stringify(data)); - - // append the sourcemap reference comment to the bottom of the file - const fileContent = await 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 writeFile(dest, updatedContent); - this.log.debug(`Created ${mapFile} from ${src}`); - } catch (error) { - this.log.warn(`Couldn't reverse map for ${src}: ${error as 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. - */ - private async patchSourcemap(src: string, dest: string): Promise { - 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 writeJson(dest, data); - this.log.debug(`Patched ${dest} from ${src}`); - } catch (error) { - this.log.warn(`Couldn't patch ${dest}: ${error as Error}`); - } - } - - private getFilePatterns(extensions: string | string[], excludeAdmin: boolean): string[] { - 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; - } - - private async findFiles(extension: string, excludeAdmin: boolean): Promise { - return await fg(this.getFilePatterns(extension, excludeAdmin), { cwd: this.rootDir }); - } - - private async createIdentitySourcemap(filename: string): Promise { - // 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 tokenizer = acorn.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(); - } - - private async startReact(scriptName: string): Promise { - 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, - }, - ); - } - - private startBrowserSync(port: number, hasReact: boolean): browserSync.BrowserSyncInstance { - this.log.notice('Starting browser-sync'); - const bs = browserSync.create(); - - const adminPath = path.resolve(this.rootDir, 'admin/'); - const config: browserSync.Options = { - 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; - } - - private async startAdapterDebug(wait: boolean): Promise { - 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.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}`); - } - - private async waitForNodeChildProcess(parentPid: number): Promise { - 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; - } - - private getChildProcesses(parentPid: number): Promise { - return new Promise((resolve, reject) => - psTree(parentPid, (err, children) => { - if (err) { - reject(err); - } else { - // fix for MacOS bug #11 - children.forEach((c: any) => { - if (c.COMM && !c.COMMAND) { - c.COMMAND = c.COMM; - } - }); - resolve(children); - } - }), - ); - } - - private async startAdapterWatch(startAdapter: boolean, doNotWatch: string[]): Promise { - // 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 (!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 (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 - }`, - ); - } - } - - private async startTscWatch(): Promise { - 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, - }); - } - - private startFileSync(destinationDir: string, mainFileSuffix: string): Promise { - this.log.notice(`Starting file system sync from ${this.rootDir}`); - const inSrc = (filename: string): string => path.join(this.rootDir, filename); - const inDest = (filename: string): string => 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 = [] as string[]; - const watcher = chokidar.watch(fg.sync(patterns), { cwd: this.rootDir }); - let ready = false; - let initialEventPromises: Promise[] = []; - 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: string): Promise => { - 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 copyFile(src, dest); - } - } catch { - this.log.warn(`Couldn't sync ${filename}`); - } - }; - watcher.on('add', (filename: string) => { - if (ready) { - void syncFile(filename); - } else if (!filename.endsWith('map') && !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: string) => { - if (!ignoreFiles.includes(filename)) { - const resPromise = syncFile(filename); - if (!ready) { - initialEventPromises.push(resPromise); - } - } - }); - watcher.on('unlink', (filename: string) => { - unlinkSync(inDest(filename)); - const map = inDest(`${filename}.map`); - if (existsSync(map)) { - unlinkSync(map); - } - }); - }); - } - - private startNodemon(baseDir: string, scriptName: string, doNotWatch: string[]): Promise { - 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: Record = { - 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', - }; - - nodemon({ - 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' as any, // wrong type definition: signal is of type "string?" - args, - }); - - nodemon - .on('log', (msg: { type: 'log' | 'info' | 'status' | 'detail' | 'fail' | 'error'; message: string }) => { - 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: any) => { - 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(); - } - - async handleNodemonDetailMsg(message: string): Promise { - 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: number, - dependencies: DependencyVersions, - backupFile: string | undefined, - useSymlinks: boolean, - ): Promise { - 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 mkdirp(dataDir); - - // 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(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 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 as any)['@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 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 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 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 as 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; - }); - } - - private isGitRepository(): boolean { - // Check if we're in a git repository by looking for .git directory - return existsSync(path.join(this.rootDir, '.git')); - } - - private async verifyIgnoreFiles(): Promise { - 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: string, command: string, allowStar: boolean): Promise => { - try { - const { stdout, stderr } = await this.getExecOutput(command, this.rootDir); - 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', - }, - ); - type Action = 'add-star' | 'add-explicit' | 'abort'; - let action: Action; - try { - const result = await prompt<{ action: Action }>({ - 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.rootDir, 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 as 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'); - } - } - - private async uploadAndAddAdapter(name: string): Promise { - // 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); - } - } - - private uploadAdapter(name: string): void { - this.log.notice(`Upload iobroker.${name}`); - this.execSync(`${IOBROKER_COMMAND} upload ${name}`, this.profileDir); - } - - private async buildLocalAdapter(): Promise { - const pkg = await this.readPackageJson(); - if (pkg.scripts?.build) { - this.log.notice(`Build iobroker.${this.adapterName}`); - this.execSync('npm run build', this.rootDir); - } - } - - private async installLocalAdapter(doInstall = true): Promise { - this.log.notice(`Install local iobroker.${this.adapterName}`); - - if (this.config?.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 readJson(path.join(this.profileDir, 'package.json')); - const depPath = tempPkg.dependencies?.[`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 rimraf(fullPath); - } - } - } - - private installRepoAdapter(adapterName: string): Promise { - 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. - */ - private getDependencies(dependencies: any): string[] { - const adapters: string[] = []; - 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'); - } - - private async withDb(method: (db: DBConnection) => Promise): Promise { - const db = new DBConnection('iobroker', this.profileDir, this.log); - await db.start(); - try { - return await method(db); - } finally { - await db.stop(); - } - } - - private async updateObject( - id: T, - method: (obj: ioBroker.ObjectIdToObjectType) => ioBroker.SettableObject>, - ): Promise { - await this.withDb(async db => { - const obj = await db.getObject(id); - if (obj) { - // @ts-expect-error fix later - await db.setObject(id, method(obj)); - } - }); - } - - private execSync(command: string, cwd: string, options?: cp.ExecSyncOptionsWithBufferEncoding): Buffer { - options = { cwd: cwd, stdio: 'inherit', ...options }; - this.log.debug(`${cwd}> ${command}`); - return cp.execSync(command, options); - } - - private getExecOutput(command: string, cwd: string): Promise<{ stdout: string; stderr: string }> { - this.log.debug(`${cwd}> ${command}`); - return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { - this.childProcesses.push( - cp.exec(command, { cwd, encoding: 'ascii' }, (err, stdout, stderr) => { - if (err) { - reject(err); - } else { - resolve({ stdout, stderr }); - } - }), - ); - }); - } - - private spawn( - command: string, - args: ReadonlyArray, - cwd: string, - options?: cp.SpawnOptions, - ): Promise { - 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')); - }); - } - - private async spawnAndAwaitOutput( - command: string, - args: ReadonlyArray, - cwd: string, - awaitMsg: string | RegExp, - options?: cp.SpawnOptions, - ): Promise { - const proc = await this.spawn(command, args, cwd, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); - return new Promise((resolve, reject) => { - const handleStream = (isStderr: boolean) => (data: Buffer) => { - 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')); - }); - }); - } - - private escapeStringRegexp(value: string): string { - // 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'); - } - - private async exit(exitCode: number, signal = 'SIGINT'): Promise { - const childPids = this.childProcesses.map(p => p.pid).filter(p => !!p) as number[]; - const tryKill = (pid: number, signal: string): void => { - try { - process.kill(pid, signal); - } catch { - // 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 as 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'; (() => new DevServer())(); From c8b84cacd3a15914bb2a8598c5d7dd8eb2631e1c Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Wed, 14 Jan 2026 13:30:43 +0000 Subject: [PATCH 06/25] Implement remote setup This required even more refactoring --- .vscode/settings.json | 2 +- dist/DevServer.js | 197 +- dist/commands/Backup.js | 11 +- dist/commands/CommandBase.js | 254 +- dist/commands/Debug.js | 35 +- dist/commands/IEnvironment.js | 1 + dist/commands/LocalDirectory.js | 133 + dist/commands/RemoteConnection.js | 137 + dist/commands/Run.js | 9 +- dist/commands/RunCommandBase.js | 218 +- dist/commands/Setup.js | 137 +- dist/commands/SetupRemote.js | 153 + dist/commands/Update.js | 17 +- dist/commands/Upload.js | 10 +- dist/commands/Watch.js | 75 +- dist/commands/utils.js | 41 +- dist/index.js | 6 +- dist/jsonConfig.js | 5 +- dist/logger.js | 30 +- package-lock.json | 19855 ++++++++++++++-------------- package.json | 164 +- src/DevServer.ts | 102 +- src/commands/Backup.ts | 6 +- src/commands/CommandBase.ts | 217 +- src/commands/Debug.ts | 18 +- src/commands/IEnvironment.ts | 15 + src/commands/LocalDirectory.ts | 156 + src/commands/RemoteConnection.ts | 158 + src/commands/Run.ts | 4 +- src/commands/RunCommandBase.ts | 79 +- src/commands/Setup.ts | 103 +- src/commands/SetupRemote.ts | 190 + src/commands/Update.ts | 8 +- src/commands/Upload.ts | 4 +- src/commands/Watch.ts | 21 +- src/commands/utils.ts | 30 + src/index.ts | 2 +- tsconfig.json | 8 +- 38 files changed, 11657 insertions(+), 10954 deletions(-) create mode 100644 dist/commands/IEnvironment.js create mode 100644 dist/commands/LocalDirectory.js create mode 100644 dist/commands/RemoteConnection.js create mode 100644 dist/commands/SetupRemote.js create mode 100644 src/commands/IEnvironment.ts create mode 100644 src/commands/LocalDirectory.ts create mode 100644 src/commands/RemoteConnection.ts create mode 100644 src/commands/SetupRemote.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index ca171117..9c1ea756 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,5 +23,5 @@ "editor.tabSize": 4, "editor.insertSpaces": true }, - "cSpell.words": ["alcalzone", "iobroker"] + "cSpell.words": ["alcalzone", "iobroker", "Pids"] } diff --git a/dist/DevServer.js b/dist/DevServer.js index 7766171b..5175df66 100644 --- a/dist/DevServer.js +++ b/dist/DevServer.js @@ -1,65 +1,37 @@ #!/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 }); -exports.DevServer = void 0; -const axios_1 = __importDefault(require("axios")); -const chalk_1 = __importDefault(require("chalk")); -const enquirer_1 = require("enquirer"); -const fs_extra_1 = require("fs-extra"); -const path = __importStar(require("node:path")); -const semver_1 = require("semver"); -const yargs_1 = __importDefault(require("yargs/yargs")); -const Backup_1 = require("./commands/Backup"); -const Debug_1 = require("./commands/Debug"); -const Run_1 = require("./commands/Run"); -const Setup_1 = require("./commands/Setup"); -const Update_1 = require("./commands/Update"); -const Upload_1 = require("./commands/Upload"); -const Watch_1 = require("./commands/Watch"); -const logger_1 = require("./logger"); +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 { 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'; -class DevServer { +export class DevServer { + log; + rootPath; + adapterName; + tempPath; + profileName; + profilePath; + config; constructor() { - const parser = (0, yargs_1.default)(process.argv.slice(2)); + 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.', { @@ -88,8 +60,7 @@ class DevServer { }, remote: { type: 'boolean', - alias: 'r', - description: 'Run ioBroker and the adapter on a remote host', + description: 'Install dev-server on a remote host and connect via SSH', }, force: { type: 'boolean', hidden: true }, symlinks: { @@ -159,19 +130,20 @@ class DevServer { .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_1.Logger(argv.verbose ? 'silly' : 'debug'); + 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_1.default.get(`https://registry.npmjs.org/${name}/latest`, { timeout: 1000 }); - if ((0, semver_1.gt)(releaseVersion, localVersion)) { + 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 (0, enquirer_1.prompt)({ + const response = await enquirer.prompt({ name: 'update', type: 'confirm', message: `Version ${releaseVersion} of ${name} is available.\nWould you like to exit and update?`, @@ -185,24 +157,25 @@ class DevServer { this.log.warn(`We strongly recommend to update ${name} as soon as possible.`); } } - catch (_a) { + catch { // ignore } } async readMyPackageJson() { - return (0, fs_extra_1.readJson)(path.join(__dirname, '..', 'package.json')); + const dirname = path.dirname(fileURLToPath(import.meta.url)); + return readJson(path.join(dirname, '..', 'package.json')); } 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'))) { + 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.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); + 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(); @@ -230,16 +203,16 @@ class DevServer { 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. ` + + 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 (0, enquirer_1.prompt)({ + const response = await enquirer.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})`), + hint: chalk.gray(`(Admin Port: ${profiles[p]['dev-server'].adminPort})`), })), }); profileName = response.profile; @@ -251,15 +224,15 @@ class DevServer { } this.profileName = profileName; this.log.debug(`Using profile name "${this.profileName}"`); - this.profileDir = path.join(this.tempDir, profileName); + this.profilePath = path.join(this.tempPath, profileName); this.adapterName = await this.findAdapterName(); } async parseConfig() { let pkg; try { - pkg = await (0, fs_extra_1.readJson)(path.join(this.profileDir, 'package.json')); + pkg = await readJson(path.join(this.profilePath, 'package.json')); } - catch (_a) { + catch { // not all commands need the config return; } @@ -267,7 +240,7 @@ class DevServer { } async findAdapterName() { try { - const ioPackage = await (0, fs_extra_1.readJson)(path.join(this.rootDir, 'io-package.json')); + 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; @@ -280,17 +253,23 @@ class DevServer { } ////////////////// Command Handlers ////////////////// async setup(adminPort, dependencies, backupFile, remote, force, useSymlinks) { - const setup = new Setup_1.Setup(this, adminPort, dependencies, backupFile, 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() { - await this.checkSetup(); - const update = new Update_1.Update(this); + this.checkSetup(); + const update = new Update(this); await update.run(); } async run(useBrowserSync = true) { - await this.checkSetup(); - const run = new Run_1.Run(this, useBrowserSync); + this.checkSetup(); + const run = new Run(this, useBrowserSync); await run.run(); } async watch(startAdapter, noInstall, doNotWatch, useBrowserSync = true) { @@ -301,26 +280,26 @@ class DevServer { else if (Array.isArray(doNotWatch)) { doNotWatchArr = doNotWatch; } - await this.checkSetup(); - const watch = new Watch_1.Watch(this, noInstall, startAdapter, doNotWatchArr, useBrowserSync); + this.checkSetup(); + const watch = new Watch(this, noInstall, startAdapter, doNotWatchArr, useBrowserSync); await watch.run(); } async debug(wait, noInstall) { - await this.checkSetup(); - const debug = new Debug_1.Debug(this, wait, noInstall); + this.checkSetup(); + const debug = new Debug(this, wait, noInstall); await debug.run(); } async upload() { - await this.checkSetup(); - const upload = new Upload_1.Upload(this); + this.checkSetup(); + const upload = new Upload(this); await upload.run(); - this.log.box(`The latest content of iobroker.${this.adapterName} was uploaded to ${this.profileDir}.`); + this.log.box(`The latest content of iobroker.${this.adapterName} was uploaded to ${this.profilePath}.`); } async backup(filename) { - await this.checkSetup(); + this.checkSetup(); this.log.notice('Creating backup'); const fullPath = path.resolve(filename); - const backup = new Backup_1.Backup(this, fullPath); + const backup = new Backup(this, fullPath); await backup.run(); } async profile() { @@ -337,45 +316,43 @@ class DevServer { ]; }); 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'), + chalk.bold('Profile Name'), + chalk.bold('Admin URL'), + chalk.bold('js-controller'), + chalk.bold('admin'), ]); - this.log.info(`The following profiles exist in ${this.tempDir}`); + this.log.info(`The following profiles exist in ${this.tempPath}`); this.log.table(table.filter(r => !!r)); } ////////////////// Command Helper Methods ////////////////// async getProfiles() { - if (!(0, fs_extra_1.existsSync)(this.tempDir)) { + if (!existsSync(this.tempPath)) { return {}; } - const entries = await (0, fs_extra_1.readdir)(this.tempDir); + const entries = await readdir(this.tempPath); 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 pkg = await readJson(path.join(this.tempPath, e, 'package.json')); const infos = pkg['dev-server']; const dependencies = pkg.dependencies; - if ((infos === null || infos === void 0 ? void 0 : infos.adminPort) && dependencies) { + if (infos?.adminPort && dependencies) { return [e, pkg]; } } - catch (_a) { + catch { return undefined; } }, {})); return pkgs.filter(p => !!p).reduce((old, [e, pkg]) => ({ ...old, [e]: pkg }), {}); } - async checkSetup() { + 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.`); + 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.profileDir, 'node_modules', CORE_MODULE); - return (0, fs_extra_1.existsSync)(jsControllerDir); + const jsControllerDir = path.join(this.profilePath, 'node_modules', CORE_MODULE); + return existsSync(jsControllerDir); } } -exports.DevServer = DevServer; -(() => new DevServer())(); diff --git a/dist/commands/Backup.js b/dist/commands/Backup.js index 7eca9f7b..3e57149f 100644 --- a/dist/commands/Backup.js +++ b/dist/commands/Backup.js @@ -1,14 +1,11 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Backup = void 0; -const CommandBase_1 = require("./CommandBase"); -class Backup extends CommandBase_1.CommandBase { +import { CommandBase, IOBROKER_COMMAND } from './CommandBase.js'; +export class Backup extends CommandBase { + filename; constructor(owner, filename) { super(owner); this.filename = filename; } async run() { - this.execSync(`${CommandBase_1.IOBROKER_COMMAND} backup "${this.filename}"`, this.profileDir); + await this.profileDir.exec(`${IOBROKER_COMMAND} backup "${this.filename}"`); } } -exports.Backup = Backup; diff --git a/dist/commands/CommandBase.js b/dist/commands/CommandBase.js index e50e584a..a5611145 100644 --- a/dist/commands/CommandBase.js +++ b/dist/commands/CommandBase.js @@ -1,68 +1,30 @@ -"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 }); -exports.CommandBase = exports.OBJECTS_DB_PORT_OFFSET = exports.STATES_DB_PORT_OFFSET = exports.HIDDEN_BROWSER_SYNC_PORT_OFFSET = exports.HIDDEN_ADMIN_PORT_OFFSET = exports.IOBROKER_COMMAND = exports.IOBROKER_CLI = void 0; -const dbConnection_1 = require("@iobroker/testing/build/tests/integration/lib/dbConnection"); -const fs_extra_1 = require("fs-extra"); -const cp = __importStar(require("node:child_process")); -const node_path_1 = __importDefault(require("node:path")); -const ps_tree_1 = __importDefault(require("ps-tree")); -const rimraf_1 = require("rimraf"); -const utils_1 = require("./utils"); -exports.IOBROKER_CLI = 'node_modules/iobroker.js-controller/iobroker.js'; -exports.IOBROKER_COMMAND = `node ${exports.IOBROKER_CLI}`; -exports.HIDDEN_ADMIN_PORT_OFFSET = 12345; -exports.HIDDEN_BROWSER_SYNC_PORT_OFFSET = 14345; -exports.STATES_DB_PORT_OFFSET = 16345; -exports.OBJECTS_DB_PORT_OFFSET = 18345; -class CommandBase { +import path from 'node:path'; +import { rimraf } from 'rimraf'; +import { LocalDirectory } from './LocalDirectory.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 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.childProcesses = []; + this.rootDir = new LocalDirectory(this.rootPath, this.log); + this.profileDir = new LocalDirectory(this.profilePath, this.log); } get log() { return this.owner.log; } - get rootDir() { - return this.owner.rootDir; + get rootPath() { + return this.owner.rootPath; } - get profileDir() { - return this.owner.profileDir; + get profilePath() { + return this.owner.profilePath; } get profileName() { return this.owner.profileName; @@ -83,186 +45,63 @@ class CommandBase { return this.adapterName === 'js-controller'; } readPackageJson() { - return (0, fs_extra_1.readJson)(node_path_1.default.join(this.rootDir, 'package.json')); + 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 */ - async readIoPackageJson() { - return (0, fs_extra_1.readJson)(node_path_1.default.join(this.rootDir, 'io-package.json')); + readIoPackageJson() { + return this.rootDir.readJson('io-package.json'); } isTypeScriptMain(mainFile) { return !!(mainFile && mainFile.endsWith('.ts')); } async installLocalAdapter(doInstall = true) { - var _a; this.log.notice(`Install local iobroker.${this.adapterName}`); if (this.config.useSymlinks) { // This is the expected relative path - const relativePath = node_path_1.default.relative(this.profileDir, this.rootDir); + const relativePath = path.relative(this.profilePath, this.rootPath); // Check if it is already used in package.json - const tempPkg = await (0, fs_extra_1.readJson)(node_path_1.default.join(this.profileDir, 'package.json')); - const depPath = (_a = tempPkg.dependencies) === null || _a === void 0 ? void 0 : _a[`iobroker.${this.adapterName}`]; + const tempPkg = await readJson(path.join(this.profilePath, 'package.json')); + const depPath = tempPkg.dependencies?.[`iobroker.${this.adapterName}`]; // If not, install it if (depPath !== relativePath) { - this.execSync(`npm install "${relativePath}"`, this.profileDir); + await this.profileDir.exec(`npm install "${relativePath}"`); } } else { - const { stdout } = await this.getExecOutput('npm pack', this.rootDir); + const { stdout } = await this.rootDir.getExecOutput('npm pack'); const filename = stdout.trim(); this.log.info(`Packed to ${filename}`); if (doInstall) { - const fullPath = node_path_1.default.join(this.rootDir, filename); - this.execSync(`npm install "${fullPath}"`, this.profileDir); - await (0, rimraf_1.rimraf)(fullPath); + const fullPath = path.join(this.rootPath, filename); + await this.profileDir.installTarball(fullPath); + await rimraf(fullPath); } } } async buildLocalAdapter() { - var _a; const pkg = await this.readPackageJson(); - if ((_a = pkg.scripts) === null || _a === void 0 ? void 0 : _a.build) { + if (pkg.scripts?.build) { this.log.notice(`Build iobroker.${this.adapterName}`); - this.execSync('npm run build', this.rootDir); + await this.rootDir.exec('npm run build'); } } - uploadAdapter(name) { + async uploadAdapter(name) { this.log.notice(`Upload iobroker.${name}`); - this.execSync(`${exports.IOBROKER_COMMAND} upload ${name}`, this.profileDir); - } - 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')); - }); - }); + await this.profileDir.exec(`${IOBROKER_COMMAND} upload ${name}`); } 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 (0, utils_1.delay)(5000); - return this.exit(exitCode, 'SIGKILL'); - } - } + 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 this.getChildProcesses(parentPid); + const processes = await getChildProcesses(parentPid); const child = processes.find(p => p.COMMAND.match(/node/i)); if (child) { return parseInt(child.PID); @@ -271,21 +110,4 @@ class CommandBase { 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); - } - })); - } } -exports.CommandBase = CommandBase; diff --git a/dist/commands/Debug.js b/dist/commands/Debug.js index 36ea87fb..06f8f4bb 100644 --- a/dist/commands/Debug.js +++ b/dist/commands/Debug.js @@ -1,13 +1,9 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Debug = void 0; -const chalk_1 = __importDefault(require("chalk")); -const CommandBase_1 = require("./CommandBase"); -const RunCommandBase_1 = require("./RunCommandBase"); -class Debug extends RunCommandBase_1.RunCommandBase { +import chalk from 'chalk'; +import { IOBROKER_CLI } from './CommandBase.js'; +import { RunCommandBase } from './RunCommandBase.js'; +export class Debug extends RunCommandBase { + wait; + noInstall; constructor(owner, wait, noInstall) { super(owner); this.wait = wait; @@ -42,36 +38,33 @@ class Debug extends RunCommandBase_1.RunCommandBase { 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}`)); + 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 ${proc.pid}`); + 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', - CommandBase_1.IOBROKER_CLI, + IOBROKER_CLI, 'debug', `${this.adapterName}.0`, ]; if (this.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}`)); + const pid = await this.profileDir.spawn('node', args, code => { + console.error(chalk.yellow(`Adapter debugging exited with code ${code}`)); return this.exit(-1); }); - if (!proc.pid) { + if (!pid) { throw new Error(`PID of adapter debugger unknown!`); } - const debugPid = await this.waitForNodeChildProcess(proc.pid); + const debugPid = await this.waitForNodeChildProcess(pid); this.log.box(`Debugger is now ${this.wait ? 'waiting' : 'available'} on process id ${debugPid}`); } } -exports.Debug = Debug; 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..a8fc1fe3 --- /dev/null +++ b/dist/commands/LocalDirectory.js @@ -0,0 +1,133 @@ +import * as cp from 'node:child_process'; +import path from 'node:path'; +import { delay, getChildProcesses, readJson } from './utils.js'; +export class LocalDirectory { + directory; + log; + childProcesses = []; + constructor(directory, log) { + this.directory = directory; + this.log = log; + } + readJson(relPath) { + return readJson(path.join(this.directory, relPath)); + } + async installTarball(tarballPath) { + await this.exec(`npm install "${tarballPath}"`); + } + exec(command) { + this.log.debug(`${this.directory}> ${command}`); + cp.execSync(command, { cwd: this.directory, stdio: 'inherit' }); + return Promise.resolve(); + } + 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..fac14771 --- /dev/null +++ b/dist/commands/RemoteConnection.js @@ -0,0 +1,137 @@ +import enquirer from 'enquirer'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { Client as SSHClient } from 'ssh2'; +import { exec as ssh2ExecAsync } from 'ssh2-exec/promises'; +export class RemoteConnection { + config; + log; + client = new SSHClient(); + homeDir; + constructor(config, log) { + this.config = config; + this.log = log; + } + async connect() { + this.log.notice(`Connecting to ${this.config.user}@${this.config.host}...`); + await new Promise((resolve, reject) => { + this.client.once('ready', () => { + resolve(); + }); + this.client.once('error', err => { + this.log.error(`SSH connection error: ${err.message}`); + 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.notice('Remote SSH connection established'); + } + close() { + this.client.end(); + } + spawn(command, args, onExit) { + throw new Error('Method not implemented.'); + } + async exec(command) { + const basePath = `~/.dev-server/${this.config.id}`; + 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 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; + } + exitChildProcesses(_signal) { + throw new Error('Method not implemented.'); + } + sendSigIntToChildProcesses() { + throw new Error('Method not implemented.'); + } + async installTarball(tarballPath) { + const filename = path.basename(tarballPath); + await this.upload(tarballPath, filename); + await this.exec(`npm install "./${filename}"`); + } + async upload(localPath, relPath) { + this.log.notice(`Uploading ${relPath} to remote host...`); + const homeDir = await this.getHomeDir(); + const remotePath = `${homeDir}/.dev-server/${this.config.id}/${relPath}`; + await new Promise((resolve, reject) => { + this.client.sftp((err, sftp) => { + if (err) { + return reject(err); + } + this.log.silly(`${localPath} -> ${remotePath}`); + sftp.fastPut(localPath, remotePath, {}, putErr => { + if (putErr) { + return reject(putErr); + } + resolve(); + }); + }); + }); + } + async getHomeDir() { + if (!this.homeDir) { + this.homeDir = (await this.getExecOutput('echo $HOME')).trim(); + } + return this.homeDir; + } +} diff --git a/dist/commands/Run.js b/dist/commands/Run.js index 79861ae1..91bbdbc9 100644 --- a/dist/commands/Run.js +++ b/dist/commands/Run.js @@ -1,8 +1,6 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Run = void 0; -const RunCommandBase_1 = require("./RunCommandBase"); -class Run extends RunCommandBase_1.RunCommandBase { +import { RunCommandBase } from './RunCommandBase.js'; +export class Run extends RunCommandBase { + useBrowserSync; constructor(owner, useBrowserSync) { super(owner); this.useBrowserSync = useBrowserSync; @@ -12,4 +10,3 @@ class Run extends RunCommandBase_1.RunCommandBase { await this.startServer(this.useBrowserSync); } } -exports.Run = Run; diff --git a/dist/commands/RunCommandBase.js b/dist/commands/RunCommandBase.js index 75e775a8..daff2964 100644 --- a/dist/commands/RunCommandBase.js +++ b/dist/commands/RunCommandBase.js @@ -1,45 +1,37 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.RunCommandBase = void 0; -const acorn_1 = __importDefault(require("acorn")); -const axios_1 = __importDefault(require("axios")); -const browser_sync_1 = __importDefault(require("browser-sync")); -const chalk_1 = __importDefault(require("chalk")); -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_events_1 = __importDefault(require("node:events")); -const node_path_1 = __importDefault(require("node:path")); -const source_map_1 = require("source-map"); -const ws_1 = __importDefault(require("ws")); -const jsonConfig_1 = require("../jsonConfig"); -const CommandBase_1 = require("./CommandBase"); -const utils_1 = require("./utils"); -class RunCommandBase extends CommandBase_1.CommandBase { - constructor() { - super(...arguments); - this.socketEvents = new node_events_1.default(); - } +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, writeFile } 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, OBJECTS_DB_PORT_OFFSET, STATES_DB_PORT_OFFSET, } from './CommandBase.js'; +import { checkPort, delay, readJson, writeJson } from './utils.js'; +export class RunCommandBase extends CommandBase { + websocket; + socketEvents = new EventEmitter(); async startJsController() { - const proc = await this.spawn('node', [ + await this.profileDir.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}`)); + ], 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(CommandBase_1.OBJECTS_DB_PORT_OFFSET)) || !(await this.waitForPort(CommandBase_1.STATES_DB_PORT_OFFSET))) { + if (!(await this.waitForPort(OBJECTS_DB_PORT_OFFSET)) || !(await this.waitForPort(STATES_DB_PORT_OFFSET))) { throw new Error(`Couldn't start js-controller`); } } @@ -49,30 +41,30 @@ class RunCommandBase extends CommandBase_1.CommandBase { let tries = 0; while (true) { try { - await (0, utils_1.checkPort)(port); + await checkPort(port); this.log.debug(`Port ${port} is available...`); return true; } - catch (_a) { + catch { if (tries++ > 30) { this.log.error(`Port ${port} is not available after 30 seconds.`); return false; } - await (0, utils_1.delay)(1000); + await delay(1000); } } } async startServer(useBrowserSync = true) { - this.log.notice(`Running inside ${this.profileDir}`); + this.log.notice(`Running inside ${this.profilePath}`); if (!this.config) { throw new Error(`Couldn't find dev-server configuration in package.json`); } - await this.waitForPort(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET); - const app = (0, express_1.default)(); - const hiddenAdminPort = this.getPort(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET); + await this.waitForPort(HIDDEN_ADMIN_PORT_OFFSET); + const app = express(); + const hiddenAdminPort = this.getPort(HIDDEN_ADMIN_PORT_OFFSET); if (this.isJSController()) { // simply forward admin as-is - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)({ + app.use(createProxyMiddleware({ target: `http://127.0.0.1:${hiddenAdminPort}`, ws: true, })); @@ -82,15 +74,15 @@ class RunCommandBase extends CommandBase_1.CommandBase { 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); + await this.createCombinedConfigProxy(app, uiCapabilities, useBrowserSync); } else if (uiCapabilities.configType === 'json') { // JSON config only - await this.createJsonConfigProxy(app, this.config, useBrowserSync); + await this.createJsonConfigProxy(app, useBrowserSync); } else { // HTML config or tabs only (or no config) - await this.createHtmlConfigProxy(app, this.config, useBrowserSync); + await this.createHtmlConfigProxy(app, useBrowserSync); } } // start express @@ -105,7 +97,8 @@ class RunCommandBase extends CommandBase_1.CommandBase { // 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')); + this.rootDir.sendSigIntToChildProcesses(); + this.profileDir.sendSigIntToChildProcesses(); } }); await new Promise((resolve, reject) => { @@ -119,7 +112,7 @@ class RunCommandBase extends CommandBase_1.CommandBase { 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 = 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'); @@ -128,9 +121,8 @@ class RunCommandBase extends CommandBase_1.CommandBase { }); 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(); + const msgString = msg?.toString(); if (typeof msgString === 'string') { try { const data = JSON.parse(msgString); @@ -145,7 +137,7 @@ class RunCommandBase extends CommandBase_1.CommandBase { break; case 1: // ping received, send pong (keep-alive) - (_a = this.websocket) === null || _a === void 0 ? void 0 : _a.send('[2]'); + this.websocket?.send('[2]'); break; } } @@ -174,7 +166,6 @@ class RunCommandBase extends CommandBase_1.CommandBase { * - tabType: 'json' (jsonTab), 'html' (HTML/React tab), or 'none' */ async getAdapterUiCapabilities() { - var _a; let configType = 'none'; let tabType = 'none'; // Check for jsonConfig files first @@ -185,7 +176,7 @@ class RunCommandBase extends CommandBase_1.CommandBase { // 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) { + 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 @@ -215,11 +206,11 @@ class RunCommandBase extends CommandBase_1.CommandBase { }; } getJsonConfigPath() { - const jsonConfigPath = node_path_1.default.resolve(this.rootDir, 'admin/jsonConfig.json'); - if ((0, fs_extra_1.existsSync)(jsonConfigPath)) { + const jsonConfigPath = path.resolve(this.rootPath, 'admin/jsonConfig.json'); + if (existsSync(jsonConfigPath)) { return jsonConfigPath; } - if ((0, fs_extra_1.existsSync)(`${jsonConfigPath}5`)) { + if (existsSync(`${jsonConfigPath}5`)) { return `${jsonConfigPath}5`; } return ''; @@ -239,16 +230,15 @@ class RunCommandBase extends CommandBase_1.CommandBase { * 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) { + 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(CommandBase_1.HIDDEN_BROWSER_SYNC_PORT_OFFSET); - const adminUrl = `http://127.0.0.1:${this.getPort(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET)}`; + 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) { @@ -262,26 +252,26 @@ class RunCommandBase extends CommandBase_1.CommandBase { // Handle jsonConfig file watching if present if (uiCapabilities.configType === 'json' && useBrowserSync && bs) { const jsonConfigFile = this.getJsonConfigPath(); - this.setupJsonFileWatch(bs, jsonConfigFile, node_path_1.default.basename(jsonConfigFile)); + 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, node_path_1.default.basename(jsonConfigFile))); + 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 = node_path_1.default.resolve(this.rootDir, 'admin/jsonTab.json'); - const jsonTab5Path = node_path_1.default.resolve(this.rootDir, 'admin/jsonTab.json5'); + 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 = node_path_1.default.resolve(this.rootDir, 'admin/tab.html'); - if ((0, fs_extra_1.existsSync)(tabHtmlPath)) { + 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...'); @@ -297,25 +287,25 @@ class RunCommandBase extends CommandBase_1.CommandBase { // 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/**'], { + 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((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)([`!${adminPattern}`, '!/browser-sync/**'], { + app.use(createProxyMiddleware([`!${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/**'], { + 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((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)({ + app.use(createProxyMiddleware({ target: adminUrl, ws: true, })); @@ -323,76 +313,76 @@ class RunCommandBase extends CommandBase_1.CommandBase { } else { // Direct admin proxy without browser-sync - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)({ + app.use(createProxyMiddleware({ target: adminUrl, ws: true, })); } } - createJsonConfigProxy(app, config, useBrowserSync = true) { + createJsonConfigProxy(app, useBrowserSync = true) { const jsonConfigFile = this.getJsonConfigPath(); - const adminUrl = `http://127.0.0.1:${this.getPort(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET)}`; + 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(CommandBase_1.HIDDEN_BROWSER_SYNC_PORT_OFFSET); + 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, node_path_1.default.basename(jsonConfigFile)); + 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, node_path_1.default.basename(jsonConfigFile))); + const { data } = await axios.get(adminUrl); + res.send(injectCode(data, this.adapterName, path.basename(jsonConfigFile))); }); // browser-sync proxy - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)(['/browser-sync/**'], { + 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((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)({ + app.use(createProxyMiddleware({ target: adminUrl, ws: true, })); } else { // Serve without BrowserSync - just proxy admin directly - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)({ + app.use(createProxyMiddleware({ target: adminUrl, ws: true, })); } return Promise.resolve(); } - async createHtmlConfigProxy(app, config, useBrowserSync = true) { + 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(CommandBase_1.HIDDEN_BROWSER_SYNC_PORT_OFFSET); + const browserSyncPort = this.getPort(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/**'], { + 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((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)([`!${adminPattern}`, '!/browser-sync/**'], { - target: `http://127.0.0.1:${this.getPort(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET)}`, + 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 = node_path_1.default.resolve(this.rootDir, 'admin/'); + const adminPath = path.resolve(this.rootPath, 'admin/'); // serve static admin files - app.use(`/adapter/${this.adapterName}`, express_1.default.static(adminPath)); + app.use(`/adapter/${this.adapterName}`, express.static(adminPath)); // admin proxy for everything else - app.use((0, http_proxy_middleware_1.legacyCreateProxyMiddleware)([`!${adminPattern}`], { - target: `http://127.0.0.1:${this.getPort(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET)}`, + app.use(createProxyMiddleware([`!${adminPattern}`], { + target: `http://127.0.0.1:${this.getPort(HIDDEN_ADMIN_PORT_OFFSET)}`, ws: true, })); } @@ -414,7 +404,7 @@ class RunCommandBase extends CommandBase_1.CommandBase { if (scripts['watch:react']) { await this.startReact('watch:react'); hasReact = true; - if ((0, fs_extra_1.existsSync)(node_path_1.default.resolve(this.rootDir, 'admin/.watch'))) { + 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/'; @@ -429,8 +419,8 @@ class RunCommandBase extends CommandBase_1.CommandBase { } startBrowserSync(port, hasReact) { this.log.notice('Starting browser-sync'); - const bs = browser_sync_1.default.create(); - const adminPath = node_path_1.default.resolve(this.rootDir, 'admin/'); + const bs = browserSync.create(); + const adminPath = path.resolve(this.rootPath, 'admin/'); const config = { server: { baseDir: adminPath, directory: true }, port: port, @@ -439,12 +429,12 @@ class RunCommandBase extends CommandBase_1.CommandBase { logLevel: 'info', reloadDelay: hasReact ? 500 : 0, reloadDebounce: hasReact ? 500 : 0, - files: [node_path_1.default.join(adminPath, '**')], + files: [path.join(adminPath, '**')], plugins: [ { module: 'bs-html-injector', options: { - files: [node_path_1.default.join(adminPath, '*.html')], + files: [path.join(adminPath, '*.html')], }, }, ], @@ -458,15 +448,14 @@ class RunCommandBase extends CommandBase_1.CommandBase { * Uploads the file to ioBroker via WebSocket when changes are detected */ setupJsonFileWatch(bs, filePath, fileName) { - if (!(0, fs_extra_1.existsSync)(filePath)) { + if (!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([ + const content = await readFile(filePath); + this.websocket?.send(JSON.stringify([ 3, 46, 'writeFile', @@ -478,22 +467,22 @@ class RunCommandBase extends CommandBase_1.CommandBase { 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, { + await this.rootDir.spawnAndAwaitOutput('npm', ['run', scriptName], /(built in|done in|watching (files )?for)/i, { shell: true, }); } async copySourcemaps() { - const outDir = node_path_1.default.join(this.profileDir, 'node_modules', `iobroker.${this.adapterName}`); + const outDir = path.join(this.profilePath, '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`); + 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 = node_path_1.default.join(this.rootDir, js); - const dest = node_path_1.default.join(outDir, js); + const src = path.join(this.rootPath, js); + const dest = path.join(outDir, js); await this.addSourcemap(src, dest, false); })); return; @@ -501,8 +490,8 @@ class RunCommandBase extends CommandBase_1.CommandBase { // 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 = node_path_1.default.join(this.rootDir, sourcemap); - const dest = node_path_1.default.join(outDir, sourcemap); + const src = path.join(this.rootPath, sourcemap); + const dest = path.join(outDir, sourcemap); await this.patchSourcemap(src, dest); })); } @@ -514,12 +503,12 @@ class RunCommandBase extends CommandBase_1.CommandBase { */ async patchSourcemap(src, dest) { try { - const data = await (0, fs_extra_1.readJson)(src); + const data = await readJson(src); if (data.version !== 3) { throw new Error(`Unsupported sourcemap version: ${data.version}`); } - data.sourceRoot = node_path_1.default.dirname(src).replace(/\\/g, '/'); - await (0, fs_extra_1.writeJson)(dest, data); + data.sourceRoot = path.dirname(src).replace(/\\/g, '/'); + await writeJson(dest, data); this.log.debug(`Patched ${dest} from ${src}`); } catch (error) { @@ -537,10 +526,10 @@ class RunCommandBase extends CommandBase_1.CommandBase { try { const mapFile = `${dest}.map`; const data = await this.createIdentitySourcemap(src.replace(/\\/g, '/')); - await (0, fs_extra_1.writeFile)(mapFile, JSON.stringify(data)); + await writeJson(mapFile, 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 = node_path_1.default.basename(mapFile); + const fileContent = await 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 @@ -553,7 +542,7 @@ class RunCommandBase extends CommandBase_1.CommandBase { } updatedContent += `//# sourceMappingURL=${filename}`; } - await (0, fs_extra_1.writeFile)(dest, updatedContent); + await writeFile(dest, updatedContent); this.log.debug(`Created ${mapFile} from ${src}`); } catch (error) { @@ -562,15 +551,15 @@ class RunCommandBase extends CommandBase_1.CommandBase { } 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, { + 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 = tokenizer.getToken(); + const token = tok.getToken(); if (token.type.label === 'eof' || !token.loc) { break; } @@ -595,7 +584,6 @@ class RunCommandBase extends CommandBase_1.CommandBase { return patterns; } async findFiles(extension, excludeAdmin) { - return await (0, fast_glob_1.default)(this.getFilePatterns(extension, excludeAdmin), { cwd: this.rootDir }); + return await fg(this.getFilePatterns(extension, excludeAdmin), { cwd: this.rootPath }); } } -exports.RunCommandBase = RunCommandBase; diff --git a/dist/commands/Setup.js b/dist/commands/Setup.js index 31488dc0..28c5e38e 100644 --- a/dist/commands/Setup.js +++ b/dist/commands/Setup.js @@ -1,18 +1,19 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Setup = void 0; -const chalk_1 = __importDefault(require("chalk")); -const enquirer_1 = require("enquirer"); -const fs_extra_1 = require("fs-extra"); -const node_os_1 = require("node:os"); -const node_path_1 = __importDefault(require("node:path")); -const rimraf_1 = require("rimraf"); -const CommandBase_1 = require("./CommandBase"); -const utils_1 = require("./utils"); -class Setup extends CommandBase_1.CommandBase { +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; @@ -23,11 +24,11 @@ class Setup extends CommandBase_1.CommandBase { } async run() { if (this.force) { - this.log.notice(`Deleting ${this.profileDir}`); - await (0, rimraf_1.rimraf)(this.profileDir); + 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.profileDir}".`); + 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; } @@ -36,23 +37,23 @@ class Setup extends CommandBase_1.CommandBase { useSymlinks: this.useSymlinks, }; await this.buildLocalAdapter(); - this.log.notice(`Setting up in ${this.profileDir}`); + 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.profileDir}.\n\n` + + 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.profileName}`) .join('\n')}\n\nto use dev-server.`); } async setupDevServer() { // create the data directory - const dataDir = node_path_1.default.join(this.profileDir, 'iobroker-data'); - await (0, fs_extra_1.mkdirp)(dataDir); + 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}-${(0, node_os_1.hostname)()}`, + hostname: `dev-${this.adapterName}-${hostname()}`, instanceStartInterval: 2000, compact: false, allowShellCommands: false, @@ -71,7 +72,7 @@ class Setup extends CommandBase_1.CommandBase { objects: { type: 'jsonl', host: '127.0.0.1', - port: this.getPort(CommandBase_1.OBJECTS_DB_PORT_OFFSET), + port: this.getPort(OBJECTS_DB_PORT_OFFSET), noFileCache: false, maxQueue: 1000, connectTimeout: 2000, @@ -88,7 +89,7 @@ class Setup extends CommandBase_1.CommandBase { states: { type: 'jsonl', host: '127.0.0.1', - port: this.getPort(CommandBase_1.STATES_DB_PORT_OFFSET), + port: this.getPort(STATES_DB_PORT_OFFSET), connectTimeout: 2000, writeFileInterval: 30000, dataDir: '', @@ -118,7 +119,7 @@ class Setup extends CommandBase_1.CommandBase { plugins: {}, dataDir: '../../iobroker-data/', }; - await (0, fs_extra_1.writeJson)(node_path_1.default.join(dataDir, 'iobroker.json'), config, { spaces: 2 }); + 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 @@ -136,18 +137,18 @@ class Setup extends CommandBase_1.CommandBase { dependencies: this.dependencies, 'dev-server': this.config, }; - await (0, fs_extra_1.writeJson)(node_path_1.default.join(this.profileDir, 'package.json'), pkg, { spaces: 2 }); + 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 (0, fs_extra_1.writeFile)(node_path_1.default.join(this.profileDir, '.npmrc'), 'install-links=false', 'utf8'); + 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 = node_path_1.default.resolve(this.backupFile); + const fullPath = path.resolve(this.backupFile); this.log.notice(`Restoring backup from ${fullPath}`); - this.execSync(`${CommandBase_1.IOBROKER_COMMAND} restore "${fullPath}"`, this.profileDir); + await this.profileDir.exec(`${IOBROKER_COMMAND} restore "${fullPath}"`); } if (this.isJSController()) { await this.installLocalAdapter(); @@ -156,7 +157,7 @@ class Setup extends CommandBase_1.CommandBase { // 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(CommandBase_1.HIDDEN_ADMIN_PORT_OFFSET); + admin.native.port = this.getPort(HIDDEN_ADMIN_PORT_OFFSET); admin.native.bind = '127.0.0.1'; return admin; }); @@ -173,7 +174,7 @@ class Setup extends CommandBase_1.CommandBase { this.log.debug(`Found ${adapterDeps.length} adapter dependencies`); for (const adapter of adapterDeps) { try { - this.installRepoAdapter(adapter); + await this.installRepoAdapter(adapter); } catch (error) { this.log.debug(`Couldn't install iobroker.${adapter}: ${error}`); @@ -203,11 +204,11 @@ class Setup extends CommandBase_1.CommandBase { }); } async installDependencies() { - this.execSync('npm install --loglevel error --production', this.profileDir); + await this.profileDir.exec('npm install --loglevel error --production'); } async verifyIgnoreFiles() { this.log.notice(`Verifying .npmignore and .gitignore`); - let relative = node_path_1.default.relative(this.rootDir, this.owner.tempDir).replace('\\', '/'); + let relative = path.relative(this.rootPath, this.owner.tempPath).replace('\\', '/'); if (relative.startsWith('..')) { // the temporary directory is outside the root, so no worries! return; @@ -215,14 +216,14 @@ class Setup extends CommandBase_1.CommandBase { if (!relative.endsWith('/')) { relative += '/'; } - const tempDirRegex = new RegExp(`\\s${(0, utils_1.escapeStringRegexp)(relative) + const tempDirRegex = new RegExp(`\\s${escapeStringRegexp(relative) .replace(/[\\/]$/, '') .replace(/(\\\\|\/)/g, '[\\/]')}`); const verifyFile = async (fileName, command, allowStar) => { try { - const { stdout, stderr } = await this.getExecOutput(command, this.rootDir); + const { stdout, stderr } = await this.rootDir.getExecOutput(command); if (stdout.match(tempDirRegex) || stderr.match(tempDirRegex)) { - this.log.error(chalk_1.default.bold(`Your ${fileName} doesn't exclude the temporary directory "${relative}"`)); + this.log.error(chalk.bold(`Your ${fileName} doesn't exclude the temporary directory "${relative}"`)); const choices = []; if (allowStar) { choices.push({ @@ -239,7 +240,7 @@ class Setup extends CommandBase_1.CommandBase { }); let action; try { - const result = await (0, enquirer_1.prompt)({ + const result = await enquirer.prompt({ name: 'action', type: 'select', message: 'What would you like to do?', @@ -247,25 +248,25 @@ class Setup extends CommandBase_1.CommandBase { }); action = result.action; } - catch (_a) { + catch { action = 'abort'; } if (action === 'abort') { return this.exit(-1); } - const filepath = node_path_1.default.resolve(this.rootDir, fileName); + const filepath = path.resolve(this.rootPath, fileName); let content = ''; - if ((0, fs_extra_1.existsSync)(filepath)) { - content = await (0, fs_extra_1.readFile)(filepath, { encoding: 'utf-8' }); + if (existsSync(filepath)) { + content = await readFile(filepath, { encoding: 'utf-8' }); } - const eol = content.match(/\r\n/) ? '\r\n' : content.match(/\n/) ? '\n' : node_os_1.EOL; + 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 (0, fs_extra_1.writeFile)(filepath, content); + await writeFile(filepath, content); } } catch (error) { @@ -274,7 +275,7 @@ class Setup extends CommandBase_1.CommandBase { }; await verifyFile('.npmignore', 'npm pack --dry-run', true); // Only verify .gitignore if we're in a git repository - if ((0, fs_extra_1.existsSync)(node_path_1.default.join(this.rootDir, '.git'))) { + if (existsSync(path.join(this.rootPath, '.git'))) { await verifyFile('.gitignore', 'git status --short --untracked-files=all', false); } else { @@ -283,20 +284,22 @@ class Setup extends CommandBase_1.CommandBase { } 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; - })) { + 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`); - this.execSync(`${CommandBase_1.IOBROKER_COMMAND} add ${name} 0`, this.profileDir); + 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 * @@ -328,9 +331,27 @@ class Setup extends CommandBase_1.CommandBase { } return adapters.filter(a => a !== 'js-controller'); } - installRepoAdapter(adapterName) { + async installRepoAdapter(adapterName) { this.log.notice(`Install iobroker.${adapterName}`); - this.execSync(`${CommandBase_1.IOBROKER_COMMAND} install ${adapterName}`, this.profileDir); + 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)); + } + }); } } -exports.Setup = Setup; diff --git a/dist/commands/SetupRemote.js b/dist/commands/SetupRemote.js new file mode 100644 index 00000000..acd17c1a --- /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 { name, version } = await this.owner.readMyPackageJson(); + this.dependencies[name] = version; + 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 index 89d8aae0..455abe69 100644 --- a/dist/commands/Update.js +++ b/dist/commands/Update.js @@ -1,23 +1,18 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Update = void 0; -const CommandBase_1 = require("./CommandBase"); -class Update extends CommandBase_1.CommandBase { +import { CommandBase } from './CommandBase.js'; +export class Update extends CommandBase { async run() { - var _a; this.log.notice('Updating everything...'); - if (!((_a = this.config) === null || _a === void 0 ? void 0 : _a.useSymlinks)) { + if (!this.config?.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.profileDir.exec('npm update --loglevel error'); + await this.uploadAdapter('admin'); await this.installLocalAdapter(); if (!this.isJSController()) { - this.uploadAdapter(this.adapterName); + await this.uploadAdapter(this.adapterName); } this.log.box(`dev-server was successfully updated.`); } } -exports.Update = Update; diff --git a/dist/commands/Upload.js b/dist/commands/Upload.js index 457f1f81..2312971d 100644 --- a/dist/commands/Upload.js +++ b/dist/commands/Upload.js @@ -1,14 +1,10 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Upload = void 0; -const CommandBase_1 = require("./CommandBase"); -class Upload extends CommandBase_1.CommandBase { +import { CommandBase } from './CommandBase.js'; +export class Upload extends CommandBase { async run() { await this.buildLocalAdapter(); await this.installLocalAdapter(); if (!this.isJSController()) { - this.uploadAdapter(this.adapterName); + await this.uploadAdapter(this.adapterName); } } } -exports.Upload = Upload; diff --git a/dist/commands/Watch.js b/dist/commands/Watch.js index b3f5cbdd..7bdd8907 100644 --- a/dist/commands/Watch.js +++ b/dist/commands/Watch.js @@ -1,17 +1,16 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Watch = void 0; -const chokidar_1 = __importDefault(require("chokidar")); -const fast_glob_1 = __importDefault(require("fast-glob")); -const fs_extra_1 = require("fs-extra"); -const node_path_1 = __importDefault(require("node:path")); -const nodemon_1 = __importDefault(require("nodemon")); -const RunCommandBase_1 = require("./RunCommandBase"); -const utils_1 = require("./utils"); -class Watch extends RunCommandBase_1.RunCommandBase { +import chokidar from 'chokidar'; +import fg from 'fast-glob'; +import { existsSync, unlinkSync } from 'node:fs'; +import { copyFile } from 'node:fs/promises'; +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; @@ -36,7 +35,6 @@ class Watch extends RunCommandBase_1.RunCommandBase { } } async startAdapterWatch() { - var _a; // figure out if we need to watch for TypeScript changes const pkg = await this.readPackageJson(); const scripts = pkg.scripts; @@ -48,35 +46,35 @@ class Watch extends RunCommandBase_1.RunCommandBase { const isTypeScriptMain = this.isTypeScriptMain(pkg.main); const mainFileSuffix = pkg.main.split('.').pop(); // start sync - const adapterRunDir = node_path_1.default.join(this.profileDir, 'node_modules', `iobroker.${this.adapterName}`); - if (!((_a = this.config) === null || _a === void 0 ? void 0 : _a.useSymlinks)) { + const adapterRunDir = path.join(this.profilePath, '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 (0, utils_1.delay)(3000); + await delay(3000); this.log.notice('Starting Nodemon'); await this.startNodemon(adapterRunDir, pkg.main, this.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}`); + `${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.spawnAndAwaitOutput('npm', ['run', 'watch:ts'], this.rootDir, /watching (files )?for/i, { + await this.rootDir.spawnAndAwaitOutput('npm', ['run', 'watch:ts'], /watching (files )?for/i, { shell: true, }); } startFileSync(destinationDir, mainFileSuffix) { - this.log.notice(`Starting file system sync from ${this.rootDir}`); - const inSrc = (filename) => node_path_1.default.join(this.rootDir, filename); - const inDest = (filename) => node_path_1.default.join(destinationDir, filename); + this.log.notice(`Starting file system sync from ${this.rootPath}`); + 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)) { @@ -84,7 +82,7 @@ class Watch extends RunCommandBase_1.RunCommandBase { } const patterns = this.getFilePatterns(patternList, true); const ignoreFiles = []; - const watcher = chokidar_1.default.watch(fast_glob_1.default.sync(patterns), { cwd: this.rootDir }); + const watcher = chokidar.watch(fg.sync(patterns), { cwd: this.rootPath }); let ready = false; let initialEventPromises = []; watcher.on('error', reject); @@ -106,15 +104,15 @@ class Watch extends RunCommandBase_1.RunCommandBase { if (filename.endsWith('.map')) { await this.patchSourcemap(src, dest); } - else if (!(0, fs_extra_1.existsSync)(inSrc(`${filename}.map`))) { + else if (!existsSync(inSrc(`${filename}.map`))) { // copy file and add sourcemap await this.addSourcemap(src, dest, true); } else { - await (0, fs_extra_1.copyFile)(src, dest); + await copyFile(src, dest); } } - catch (_a) { + catch { this.log.warn(`Couldn't sync ${filename}`); } }; @@ -122,7 +120,7 @@ class Watch extends RunCommandBase_1.RunCommandBase { if (ready) { void syncFile(filename); } - else if (!filename.endsWith('map') && !(0, fs_extra_1.existsSync)(inDest(filename))) { + else if (!filename.endsWith('map') && !existsSync(inDest(filename))) { // ignore files during initial sync if they don't exist in the target directory (except for sourcemaps) ignoreFiles.push(filename); } @@ -139,16 +137,16 @@ class Watch extends RunCommandBase_1.RunCommandBase { } }); watcher.on('unlink', (filename) => { - (0, fs_extra_1.unlinkSync)(inDest(filename)); + unlinkSync(inDest(filename)); const map = inDest(`${filename}.map`); - if ((0, fs_extra_1.existsSync)(map)) { - (0, fs_extra_1.unlinkSync)(map); + if (existsSync(map)) { + unlinkSync(map); } }); }); } startNodemon(baseDir, scriptName, doNotWatch) { - const script = node_path_1.default.resolve(baseDir, scriptName); + const script = path.resolve(baseDir, scriptName); this.log.notice(`Starting nodemon for ${script}`); let isExiting = false; process.on('SIGINT', () => { @@ -156,12 +154,12 @@ class Watch extends RunCommandBase_1.RunCommandBase { }); const args = this.isJSController() ? [] : ['--debug', '0']; const ignoreList = [ - node_path_1.default.join(baseDir, 'admin'), + path.join(baseDir, 'admin'), // avoid recursively following symlinks - node_path_1.default.join(baseDir, '.dev-server'), + path.join(baseDir, '.dev-server'), ]; if (doNotWatch.length > 0) { - doNotWatch.forEach(entry => ignoreList.push(node_path_1.default.join(baseDir, entry))); + doNotWatch.forEach(entry => ignoreList.push(path.join(baseDir, entry))); } // Determine the appropriate execMap const execMap = { @@ -169,7 +167,7 @@ class Watch extends RunCommandBase_1.RunCommandBase { mjs: 'node --inspect --preserve-symlinks --preserve-symlinks-main', ts: 'node --inspect --preserve-symlinks --preserve-symlinks-main -r @alcalzone/esbuild-register', }; - (0, nodemon_1.default)({ + nodemon({ script, cwd: baseDir, stdin: false, @@ -184,7 +182,7 @@ class Watch extends RunCommandBase_1.RunCommandBase { signal: 'SIGINT', // wrong type definition: signal is of type "string?" args, }); - nodemon_1.default + nodemon .on('log', (msg) => { if (isExiting) { return; @@ -226,7 +224,7 @@ class Watch extends RunCommandBase_1.RunCommandBase { 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(); + nodemon.restart(); } }); } @@ -241,4 +239,3 @@ class Watch extends RunCommandBase_1.RunCommandBase { this.log.box(`Debugger is now available on process id ${debugPid}`); } } -exports.Watch = Watch; diff --git a/dist/commands/utils.js b/dist/commands/utils.js index 445d5d77..e93ef36d 100644 --- a/dist/commands/utils.js +++ b/dist/commands/utils.js @@ -1,20 +1,25 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.escapeStringRegexp = escapeStringRegexp; -exports.delay = delay; -exports.checkPort = checkPort; -const node_net_1 = require("node:net"); -function escapeStringRegexp(value) { +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'); } -function delay(ms) { +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)); } -function checkPort(port, host = '127.0.0.1', timeout = 1000) { +export function checkPort(port, host = '127.0.0.1', timeout = 1000) { return new Promise((resolve, reject) => { - const socket = new node_net_1.Socket(); + const socket = new Socket(); const onError = (error) => { socket.destroy(); reject(new Error(error)); @@ -28,3 +33,19 @@ function checkPort(port, host = '127.0.0.1', timeout = 1000) { }); }); } +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 f602d43d..cc224098 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,4 +1,2 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const DevServer_1 = require("./DevServer"); -(() => new DevServer_1.DevServer())(); +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('', `