From e78447bda8e5428d6ae041adda11495f668c2d10 Mon Sep 17 00:00:00 2001 From: reluc Date: Fri, 24 Jan 2025 17:03:42 +0100 Subject: [PATCH 01/21] refactor(cli): drop vm2 and use NodeJS vm module --- package-lock.json | 54 ++++++------ packages/cli/package.json | 3 +- packages/cli/src/cli-default-servient.ts | 102 ++++++++--------------- packages/cli/src/cli.ts | 2 +- packages/cli/src/compiler-function.ts | 15 ++++ packages/cli/test/runtime-test.ts | 47 +++-------- 6 files changed, 91 insertions(+), 132 deletions(-) create mode 100644 packages/cli/src/compiler-function.ts diff --git a/package-lock.json b/package-lock.json index e9afb9754..062dae2d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -462,8 +462,10 @@ }, "node_modules/@jsep-plugin/assignment": { "version": "1.3.0", - "dev": true, "license": "MIT", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "dev": true, "engines": { "node": ">= 10.16.0" }, @@ -473,8 +475,10 @@ }, "node_modules/@jsep-plugin/regex": { "version": "1.0.4", - "dev": true, "license": "MIT", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "dev": true, "engines": { "node": ">= 10.16.0" }, @@ -2160,6 +2164,7 @@ }, "node_modules/acorn-walk": { "version": "8.3.4", + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -3597,8 +3602,9 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4798,9 +4804,11 @@ } }, "node_modules/express": { - "version": "4.21.2", "dev": true, "license": "MIT", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4855,6 +4863,12 @@ "dev": true, "license": "MIT" }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true + }, "node_modules/express/node_modules/safe-buffer": { "version": "5.2.1", "dev": true, @@ -6504,8 +6518,10 @@ }, "node_modules/jsep": { "version": "1.4.0", - "dev": true, "license": "MIT", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "dev": true, "engines": { "node": ">= 10.16.0" } @@ -6526,7 +6542,6 @@ "node_modules/json-schema-faker": { "version": "0.5.9", "dev": true, - "license": "MIT", "dependencies": { "json-schema-ref-parser": "^6.1.0", "jsonpath-plus": "^10.3.0" @@ -6999,8 +7014,9 @@ }, "node_modules/micromatch": { "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, - "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -9042,9 +9058,10 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "dev": true, - "license": "MIT" + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true }, "node_modules/path-type": { "version": "4.0.0", @@ -11657,20 +11674,6 @@ "extsprintf": "^1.2.0" } }, - "node_modules/vm2": { - "version": "3.9.18", - "license": "MIT", - "dependencies": { - "acorn": "^8.7.0", - "acorn-walk": "^8.2.0" - }, - "bin": { - "vm2": "bin/vm2" - }, - "engines": { - "node": ">=6.0" - } - }, "node_modules/web-streams-polyfill": { "version": "4.1.0", "license": "MIT", @@ -12453,8 +12456,7 @@ "ajv": "^8.11.0", "commander": "^9.1.0", "dotenv": "^16.4.7", - "lodash": "^4.17.21", - "vm2": "3.9.18" + "lodash": "^4.17.21" }, "bin": { "wot-servient": "bin/index.js" diff --git a/packages/cli/package.json b/packages/cli/package.json index 53d9047e3..8517ac331 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -29,8 +29,7 @@ "ajv": "^8.11.0", "commander": "^9.1.0", "dotenv": "^16.4.7", - "lodash": "^4.17.21", - "vm2": "3.9.18" + "lodash": "^4.17.21" }, "scripts": { "build": "tsc -b", diff --git a/packages/cli/src/cli-default-servient.ts b/packages/cli/src/cli-default-servient.ts index 0c58007de..cc789025e 100644 --- a/packages/cli/src/cli-default-servient.ts +++ b/packages/cli/src/cli-default-servient.ts @@ -25,8 +25,9 @@ import { HttpServer, HttpClientFactory, HttpsClientFactory } from "@node-wot/bin import { CoapServer, CoapClientFactory, CoapsClientFactory } from "@node-wot/binding-coap"; import { MqttBrokerServer, MqttClientFactory } from "@node-wot/binding-mqtt"; import { FileClientFactory } from "@node-wot/binding-file"; -import { CompilerFunction, NodeVM } from "vm2"; import { ThingModelHelpers } from "@thingweb/thing-model"; +import { createContext, Script } from "vm"; +import { CompilerFunction } from "./compiler-function"; const { debug, error, info } = createLoggers("cli", "cli-default-servient"); @@ -159,46 +160,6 @@ export default class DefaultServient extends Servient { this.addClientFactory(new MqttClientFactory()); } - /** - * Runs the script in a new sandbox - * @param {string} code - the script to run - * @param {string} filename - the filename of the script - */ - public runScript(code: string, filename = "script"): unknown { - if (!this.runtime) { - throw new Error("WoT runtime not loaded; have you called start()?"); - } - const helpers = new Helpers(this); - const context = { - WoT: this.runtime, - WoTHelpers: helpers, - ModelHelpers: new ThingModelHelpers(helpers), - }; - - const vm = new NodeVM({ - sandbox: context, - }); - - const listener = (err: Error) => { - this.logScriptError(`Asynchronous script error '${filename}'`, err); - // TODO: clean up script resources - process.exit(1); - }; - process.prependListener("uncaughtException", listener); - this.uncaughtListeners.push(listener); - - try { - return vm.run(code, filename); - } catch (err) { - if (err instanceof Error) { - this.logScriptError(`Servient found error in script '${filename}'`, err); - } else { - error(`Servient found error in script '${filename}' ${err}`); - } - return undefined; - } - } - /** * Runs the script in privileged context (dangerous). In practice, this means that the script can * require system modules. @@ -206,26 +167,27 @@ export default class DefaultServient extends Servient { * @param {string} filename - the filename of the script * @param {object} options - pass cli variables or envs to the script */ - public runPrivilegedScript(code: string, filename = "script", options: ScriptOptions = {}): unknown { + public runScript(code: string, filename = "script", options: ScriptOptions = {}): unknown { if (!this.runtime) { throw new Error("WoT runtime not loaded; have you called start()?"); } const helpers = new Helpers(this); - const context = { + + options.compiler ??= (code) => code; + + const compiledCode = options.compiler(code, filename); + const script = new Script(compiledCode, filename); + process.argv = options.argv ?? []; + process.env = options.env ?? process.env; + const context = createContext({ + ...globalThis, + process, + require, + console, + exports: {}, WoT: this.runtime, WoTHelpers: helpers, ModelHelpers: new ThingModelHelpers(helpers), - }; - - const vm = new NodeVM({ - sandbox: context, - require: { - external: true, - builtin: ["*"], - }, - argv: options.argv, - compiler: options.compiler, - env: options.env, }); const listener = (err: Error) => { @@ -233,11 +195,12 @@ export default class DefaultServient extends Servient { // TODO: clean up script resources process.exit(1); }; + process.prependListener("uncaughtException", listener); this.uncaughtListeners.push(listener); try { - return vm.run(code, filename); + return script.runInContext(context, { displayErrors: true }); } catch (err) { if (err instanceof Error) { this.logScriptError(`Servient found error in privileged script '${filename}'`, err); @@ -273,6 +236,7 @@ export default class DefaultServient extends Servient { .then((myWoT) => { info("DefaultServient started"); this.runtime = myWoT; + // TODO think about builder pattern that starts with produce() ends with expose(), which exposes/publishes the Thing myWoT .produce({ @@ -296,11 +260,15 @@ export default class DefaultServient extends Servient { description: "Stop servient", output: { type: "string" }, }, - runScript: { - description: "Run script", - input: { type: "string" }, - output: { type: "string" }, - }, + ...(this.config.servient.scriptAction === true + ? { + runScript: { + description: "Run script", + input: { type: "string" }, + output: { type: "string" }, + }, + } + : {}), }, }) .then((thing) => { @@ -321,12 +289,14 @@ export default class DefaultServient extends Servient { await this.shutdown(); return undefined; }); - thing.setActionHandler("runScript", async (script) => { - const scriptv = await Helpers.parseInteractionOutput(script); - debug("running script", scriptv); - this.runScript(scriptv as string); - return undefined; - }); + if (this.config.servient.scriptAction === true) { + thing.setActionHandler("runScript", async (script) => { + const scriptv = await Helpers.parseInteractionOutput(script); + debug("running script", scriptv); + this.runScript(scriptv as string); + return undefined; + }); + } thing.setPropertyReadHandler("things", async () => { debug("returning things"); return this.getThings(); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b4adf1efc..dc5578f91 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -296,7 +296,7 @@ const runScripts = async function (servient: DefaultServient, scripts: Array string; diff --git a/packages/cli/test/runtime-test.ts b/packages/cli/test/runtime-test.ts index 3030e4d61..4de37d83e 100644 --- a/packages/cli/test/runtime-test.ts +++ b/packages/cli/test/runtime-test.ts @@ -58,40 +58,40 @@ class WoTRuntimeTest { } @test "should provide cli args"() { - const envScript = `module.exports = process.argv[0]`; + const envScript = `process.argv[0]`; - const test = WoTRuntimeTest.servient.runPrivilegedScript(envScript, undefined, { argv: ["myArg"] }); + const test = WoTRuntimeTest.servient.runScript(envScript, undefined, { argv: ["myArg"] }); assert.equal(test, "myArg"); } @test "should use the compiler function"() { const envScript = `this is not js`; - const test = WoTRuntimeTest.servient.runPrivilegedScript(envScript, undefined, { + const test = WoTRuntimeTest.servient.runScript(envScript, undefined, { compiler: () => { - return "module.exports = 'ok'"; + return "'ok'"; }, }); assert.equal(test, "ok"); } @test "should provide env variables"() { - const envScript = `module.exports = process.env.MY_VAR`; - const test = WoTRuntimeTest.servient.runPrivilegedScript(envScript, undefined, { env: { MY_VAR: "test" } }); + const envScript = `process.env.MY_VAR`; + const test = WoTRuntimeTest.servient.runScript(envScript, undefined, { env: { MY_VAR: "test" } }); assert.equal(test, "test"); } @test "should hide system env variables"() { const envScript = `module.exports = process.env.OS`; - const test = WoTRuntimeTest.servient.runPrivilegedScript(envScript); + const test = WoTRuntimeTest.servient.runScript(envScript); assert.equal(test, undefined); } @test "should require node builtin module"() { - const envScript = `module.exports = require("fs")`; + const envScript = `require("fs")`; - const test = WoTRuntimeTest.servient.runPrivilegedScript(envScript); + const test = WoTRuntimeTest.servient.runScript(envScript); assert.equal(test, fs); } @@ -101,9 +101,6 @@ class WoTRuntimeTest { assert.doesNotThrow(() => { WoTRuntimeTest.servient.runScript(failNowScript); }); - assert.doesNotThrow(() => { - WoTRuntimeTest.servient.runPrivilegedScript(failNowScript); - }); } @test "should catch bad errors"() { @@ -112,12 +109,9 @@ class WoTRuntimeTest { assert.doesNotThrow(() => { WoTRuntimeTest.servient.runScript(failNowScript); }); - assert.doesNotThrow(() => { - WoTRuntimeTest.servient.runPrivilegedScript(failNowScript); - }); } - @test "should catch bad asynchronous errors for runScript"(done: Mocha.Done) { + @test "should catch bad asynchronous errors"(done: Mocha.Done) { // Mocha does not like string errors: https://github.com/trufflesuite/ganache-cli/issues/658 // so here I am removing its listeners for uncaughtException. // WARNING: Remove this line as soon the issue is resolved. @@ -139,27 +133,6 @@ class WoTRuntimeTest { }); } - @test "should catch bad asynchronous errors for runPrivilegedScript"(done: Mocha.Done) { - // Mocha does not like string errors: https://github.com/trufflesuite/ganache-cli/issues/658 - // so here I am removing its listeners for uncaughtException. - // WARNING: Remove this line as soon the issue is resolved. - const listeners = this.clearUncaughtListeners(); - let called = false; - - this.mockupProcessExitWithFunction(() => { - if (!called) { - done(); - this.restoreUncaughtListeners(listeners); - called = true; - } - }); - - const failThenScript = `setTimeout( () => { throw "Bad asynchronous error in Servient sandbox"; }, 1);`; - assert.doesNotThrow(() => { - WoTRuntimeTest.servient.runPrivilegedScript(failThenScript); - }); - } - private mockupProcessExitWithFunction(func: () => void) { // Mockup is needed cause servient will call process.exit() // eslint-disable-next-line @typescript-eslint/ban-ts-comment From abb813c671efe516d6189f0fe686e448e3be6f7a Mon Sep 17 00:00:00 2001 From: reluc Date: Fri, 24 Jan 2025 17:12:17 +0100 Subject: [PATCH 02/21] refactor(cli/default-serivent): use async await in run method --- packages/cli/src/cli-default-servient.ts | 153 +++++++++++------------ 1 file changed, 71 insertions(+), 82 deletions(-) diff --git a/packages/cli/src/cli-default-servient.ts b/packages/cli/src/cli-default-servient.ts index cc789025e..ec4e14d2f 100644 --- a/packages/cli/src/cli-default-servient.ts +++ b/packages/cli/src/cli-default-servient.ts @@ -229,89 +229,78 @@ export default class DefaultServient extends Servient { /** * start */ - public start(): Promise { - return new Promise((resolve, reject) => { - super - .start() - .then((myWoT) => { - info("DefaultServient started"); - this.runtime = myWoT; - - // TODO think about builder pattern that starts with produce() ends with expose(), which exposes/publishes the Thing - myWoT - .produce({ - title: "servient", - description: "node-wot CLI Servient", - properties: { - things: { - type: "object", - description: "Get things", - observable: false, - readOnly: true, - }, - }, - actions: { - setLogLevel: { - description: "Set log level", - input: { oneOf: [{ type: "string" }, { type: "number" }] }, - output: { type: "string" }, - }, - shutdown: { - description: "Stop servient", - output: { type: "string" }, - }, - ...(this.config.servient.scriptAction === true - ? { - runScript: { - description: "Run script", - input: { type: "string" }, - output: { type: "string" }, - }, - } - : {}), - }, - }) - .then((thing) => { - thing.setActionHandler("setLogLevel", async (level) => { - const ll = await Helpers.parseInteractionOutput(level); - if (typeof ll === "number") { - this.setLogLevel(ll as number); - } else if (typeof ll === "string") { - this.setLogLevel(ll as string); - } else { - // try to convert it to strings - this.setLogLevel(ll + ""); - } - return `Log level set to '${this.logLevel}'`; - }); - thing.setActionHandler("shutdown", async () => { - debug("shutting down by remote"); - await this.shutdown(); - return undefined; - }); - if (this.config.servient.scriptAction === true) { - thing.setActionHandler("runScript", async (script) => { - const scriptv = await Helpers.parseInteractionOutput(script); - debug("running script", scriptv); - this.runScript(scriptv as string); - return undefined; - }); - } - thing.setPropertyReadHandler("things", async () => { - debug("returning things"); - return this.getThings(); - }); - thing - .expose() - .then(() => { - // pass on WoTFactory - resolve(myWoT); - }) - .catch((err) => reject(err)); - }); - }) - .catch((err) => reject(err)); + public async start(): Promise { + const superWoT = await super.start(); + this.runtime = superWoT; + + info("DefaultServient started"); + + const servientProducedThing = await superWoT.produce({ + title: "servient", + description: "node-wot CLI Servient", + properties: { + things: { + type: "object", + description: "Get things", + observable: false, + readOnly: true, + }, + }, + actions: { + setLogLevel: { + description: "Set log level", + input: { oneOf: [{ type: "string" }, { type: "number" }] }, + output: { type: "string" }, + }, + shutdown: { + description: "Stop servient", + output: { type: "string" }, + }, + ...(this.config.servient.scriptAction === true + ? { + runScript: { + description: "Run script", + input: { type: "string" }, + output: { type: "string" }, + }, + } + : {}), + }, }); + + servientProducedThing.setActionHandler("setLogLevel", async (level) => { + const ll = await Helpers.parseInteractionOutput(level); + if (typeof ll === "number") { + this.setLogLevel(ll as number); + } else if (typeof ll === "string") { + this.setLogLevel(ll as string); + } else { + // try to convert it to strings + this.setLogLevel(ll + ""); + } + return `Log level set to '${this.logLevel}'`; + }); + servientProducedThing.setActionHandler("shutdown", async () => { + debug("shutting down by remote"); + await this.shutdown(); + return undefined; + }); + if (this.config.servient.scriptAction === true) { + servientProducedThing.setActionHandler("runScript", async (script) => { + const scriptv = await Helpers.parseInteractionOutput(script); + debug("running script", scriptv); + this.runScript(scriptv as string); + return undefined; + }); + } + servientProducedThing.setPropertyReadHandler("things", async () => { + debug("returning things"); + return this.getThings(); + }); + + await servientProducedThing.expose(); + + return superWoT; } public async shutdown(): Promise { From ee16e044aefb7d04946c6768e10eb9dda4353aba Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 29 Jan 2025 14:58:30 +0100 Subject: [PATCH 03/21] refactor(cli): better code organization and minor improvements logging cli.ts log now enables by default error and warning messages. --- package-lock.json | 3 + packages/cli/package.json | 1 + packages/cli/src/cli.ts | 298 +++--------------- packages/cli/src/config-builder.ts | 42 +++ .../cli/src/parsers/config-file-parser.ts | 35 ++ .../cli/src/parsers/config-params-parser.ts | 48 +++ packages/cli/src/parsers/index.ts | 17 + packages/cli/src/parsers/ip-parser.ts | 23 ++ packages/cli/src/script-runner.ts | 88 ++++++ packages/cli/src/utils/index.ts | 16 + packages/cli/src/utils/load-compiler.ts | 35 ++ packages/cli/src/utils/load-env-variables.ts | 26 ++ 12 files changed, 381 insertions(+), 251 deletions(-) create mode 100644 packages/cli/src/config-builder.ts create mode 100644 packages/cli/src/parsers/config-file-parser.ts create mode 100644 packages/cli/src/parsers/config-params-parser.ts create mode 100644 packages/cli/src/parsers/index.ts create mode 100644 packages/cli/src/parsers/ip-parser.ts create mode 100644 packages/cli/src/script-runner.ts create mode 100644 packages/cli/src/utils/index.ts create mode 100644 packages/cli/src/utils/load-compiler.ts create mode 100644 packages/cli/src/utils/load-env-variables.ts diff --git a/package-lock.json b/package-lock.json index 062dae2d6..fbe0796eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3688,6 +3688,8 @@ "node_modules/debug": { "version": "4.4.0", "license": "MIT", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dependencies": { "ms": "^2.1.3" }, @@ -12455,6 +12457,7 @@ "@thingweb/thing-model": "^1.0.4", "ajv": "^8.11.0", "commander": "^9.1.0", + "debug": "^4.4.0", "dotenv": "^16.4.7", "lodash": "^4.17.21" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 8517ac331..b12e3959e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,6 +28,7 @@ "@thingweb/thing-model": "^1.0.4", "ajv": "^8.11.0", "commander": "^9.1.0", + "debug": "^4.4.0", "dotenv": "^16.4.7", "lodash": "^4.17.21" }, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index dc5578f91..123dbae6c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,22 +14,23 @@ ********************************************************************************/ // default implementation of W3C WoT Servient (http(s) and file bindings) -import DefaultServient from "./cli-default-servient"; -import ErrnoException = NodeJS.ErrnoException; +import DefaultServient, { ScriptOptions } from "./cli-default-servient"; // tools -import fs = require("fs"); -import * as dotenv from "dotenv"; import * as path from "path"; -import { Command, InvalidArgumentError, Argument } from "commander"; -import Ajv, { ValidateFunction, ErrorObject } from "ajv"; +import { Command, Argument } from "commander"; +import Ajv, { ValidateFunction } from "ajv"; import ConfigSchema from "./wot-servient-schema.conf.json"; -import _ from "lodash"; import { version } from "@node-wot/core/package.json"; import { createLoggers } from "@node-wot/core"; -import inspector from "inspector"; +import { buildConfig } from "./config-builder"; +import { loadCompiler, loadEnvVariables } from "./utils"; +import { runScripts } from "./script-runner"; +import { readdir } from "fs/promises"; +import * as logger from "debug"; +import { parseConfigFile, parseConfigParams, parseIp } from "./parsers"; -const { error, info, warn } = createLoggers("cli", "cli"); +const { error, info, warn, debug } = createLoggers("cli", "cli"); const program = new Command(); const ajv = new Ajv({ strict: true }); @@ -37,8 +38,6 @@ const schemaValidator = ajv.compile(ConfigSchema) as ValidateFunction; const defaultFile = "wot-servient.conf.json"; const baseDir = "."; -const dotEnvConfigParameters: DotEnvConfigParameter = {}; - // General commands program .name("wot-servient") @@ -115,87 +114,19 @@ VAR1=Value1 VAR2=Value2` ); -// Typings -type DotEnvConfigParameter = { - [key: string]: unknown; -}; -interface DebugParams { - shouldBreak: boolean; - host: string; - port: number; -} - -// Parsers & validators -function parseIp(value: string, previous: string) { - if (!/^([a-z]*|[\d.]*)(:[0-9]{2,5})?$/.test(value)) { - throw new InvalidArgumentError("Invalid host:port combo"); - } - - return value; -} -function parseConfigFile(filename: string, previous: string) { - try { - const open = filename || path.join(baseDir, defaultFile); - const data = fs.readFileSync(open, "utf-8"); - if (!schemaValidator(JSON.parse(data))) { - throw new InvalidArgumentError( - `Config file contains invalid an JSON: ${(schemaValidator.errors ?? []) - .map((o: ErrorObject) => o.message) - .join("\n")}` - ); - } - return filename; - } catch (err) { - throw new InvalidArgumentError(`Error reading config file: ${err}`); - } -} -function parseConfigParams(param: string, previous: unknown) { - // Validate key-value pair - if (!/^([a-zA-Z0-9_.]+):=([a-zA-Z0-9_]+)$/.test(param)) { - throw new InvalidArgumentError("Invalid key-value pair"); - } - const fieldNamePath = param.split(":=")[0]; - const fieldNameValue = param.split(":=")[1]; - let fieldNameValueCast; - if (Number(fieldNameValue)) { - fieldNameValueCast = +fieldNameValue; - } else if (fieldNameValue === "true" || fieldNameValue === "false") { - fieldNameValueCast = Boolean(fieldNameValue); - } else { - fieldNameValueCast = fieldNamePath; - } - - // Build object using dot-notation JSON path - const obj = _.set({}, fieldNamePath, fieldNameValueCast); - if (!schemaValidator(obj)) { - throw new InvalidArgumentError( - `Config parameter '${param}' is not valid: ${(schemaValidator.errors ?? []) - .map((o: ErrorObject) => o.message) - .join("\n")}` - ); - } - // Concatenate validated parameters - let result = previous ?? {}; - result = _.merge(result, obj); - return result; -} - // CLI options declaration program .option("-i, --inspect [host]:[port]", "activate inspector on host:port (default: 127.0.0.1:9229)", parseIp) .option("-ib, --inspect-brk [host]:[port]", "activate inspector on host:port (default: 127.0.0.1:9229)", parseIp) .option("-c, --client-only", "do not start any servers (enables multiple instances without port conflicts)") .option("-cp, --compiler ", "load module as a compiler") - .option( - "-f, --config-file ", - "load configuration from specified file", - parseConfigFile, - "wot-servient.conf.json" + .option("-f, --config-file ", "load configuration from specified file", (value, previous) => + parseConfigFile(value, previous, schemaValidator) ) .option( "-p, --config-params ", "override configuration parameters [key1:=value1 key2:=value2 ...] (e.g. http.port:=8080)", - parseConfigParams + (value, previous) => parseConfigParams(value, previous, schemaValidator) ); // CLI arguments @@ -206,189 +137,54 @@ program.addArgument( ) ); -program.parse(process.argv); -const options = program.opts(); -const args = program.args; - -// .env parsing -const env: dotenv.DotenvConfigOutput = dotenv.config(); -const errorNoException: ErrnoException | undefined = env.error; -if (errorNoException?.code !== "ENOENT") { - throw env.error; -} else if (env.parsed) { - for (const [key, value] of Object.entries(env.parsed)) { - // Parse and validate on configfile-related entries - if (key.startsWith("config.")) { - dotEnvConfigParameters[key.replace("config.", "")] = value; - } +program.action(async function (_, options, cmd) { + if (process.env.DEBUG == null) { + // by default enable error logs and warnings + // user can override it using DEBUG env + logger.enable("node-wot:**:error"); + logger.enable("node-wot:**:warn"); } -} -// Functions -async function buildConfig(): Promise { - const fileToOpen = options?.configFile ?? path.join(baseDir, defaultFile); - let configFileData = {}; + const args = cmd.args; + const env = loadEnvVariables(); + const defaultFilePath = path.join(baseDir, defaultFile); + let servient: DefaultServient; + + debug("command line options %O", options); + debug("command line arguments %O", args); + debug("command line environment variables", args); - // JSON config file try { - configFileData = JSON.parse(await fs.promises.readFile(fileToOpen, "utf-8")); + const config = await buildConfig(options, defaultFilePath, env); + servient = new DefaultServient(options.clientOnly, config); } catch (err) { - error(`WoT-Servient config file error: ${err}`); - } - - // .env file - for (const [key, value] of Object.entries(dotEnvConfigParameters)) { - const obj = _.set({}, key, value); - configFileData = _.merge(configFileData, obj); - } - - // CLI arguments - if (options?.configParams != null) { - configFileData = _.merge(configFileData, options.configParams); - } - - return configFileData; -} -const loadCompilerFunction = function (compilerModule: string | undefined) { - if (compilerModule != null) { - const compilerMod = require(compilerModule); - - if (compilerMod.create == null) { - throw new Error("No create function defined for " + compilerModule); + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT" || options.configFile != null) { + error("WoT-Servient config file error. %O", err); + process.exit((err as NodeJS.ErrnoException).errno ?? 1); } - const compilerObject = compilerMod.create(); - - if (compilerObject.compile == null) { - throw new Error("No compile function defined for create return object"); - } - return compilerObject.compile; - } - return undefined; -}; -const loadEnvVariables = function () { - const env: dotenv.DotenvConfigOutput = dotenv.config(); - - const errorNoException: ErrnoException | undefined = env.error; - // ignore file not found but throw otherwise - if (errorNoException?.code !== "ENOENT") { - throw env.error; + warn(`WoT-Servient using defaults as %s does not exist`, defaultFile); + servient = new DefaultServient(options.clientOnly); } - return env; -}; - -const runScripts = async function (servient: DefaultServient, scripts: Array, debug?: DebugParams) { - const env = loadEnvVariables(); - const launchScripts = (scripts: Array) => { - const compile = loadCompilerFunction(options.compiler); - scripts.forEach((fname: string) => { - info(`WoT-Servient reading script ${fname}`); - fs.readFile(fname, "utf8", (err, data) => { - if (err) { - error(`WoT-Servient experienced error while reading script. ${err}`); - } else { - // limit printout to first line - info( - `WoT-Servient running script '${data.substr(0, data.indexOf("\n")).replace("\r", "")}'... (${ - data.split(/\r\n|\r|\n/).length - } lines)` - ); + await servient.start(); - fname = path.resolve(fname); - servient.runScript(data, fname, { - argv: args, - env: env.parsed, - compiler: compile, - }); - } - }); - }); + const scriptOptions: ScriptOptions = { + env, + argv: args, + compiler: loadCompiler(options.compiler), }; - if (debug && debug.shouldBreak) { - // Activate inspector only if is not already opened and wait for the debugger to attach - inspector.url() == null && inspector.open(debug.port, debug.host, true); - - // Set a breakpoint at the first line of of first script - // the breakpoint gives time to inspector clients to set their breakpoints - const session = new inspector.Session(); - session.connect(); - session.post("Debugger.enable", (error: Error) => { - if (error != null) { - warn("Cannot set breakpoint; reason: cannot enable debugger"); - warn(error.toString()); - } - - session.post( - "Debugger.setBreakpointByUrl", - { - lineNumber: 0, - url: "file:///" + path.resolve(scripts[0]).replace(/\\/g, "/"), - }, - (err: Error | null) => { - if (err != null) { - warn("Cannot set breakpoint"); - warn(error.toString()); - } - launchScripts(scripts); - } - ); - }); - } else { - // Activate inspector only if is not already opened and don't wait - debug != null && inspector.url() == null && inspector.open(debug.port, debug.host, false); - launchScripts(scripts); + if (args.length > 0) { + return runScripts(servient, args, scriptOptions, options.inspect ?? options.inspectBrk); } -}; -const runAllScripts = function (servient: DefaultServient, debug?: DebugParams) { - fs.readdir(baseDir, (err, files) => { - if (err) { - warn(`WoT-Servient experienced error while loading directory. ${err}`); - return; - } + const files = await readdir(baseDir); + const scripts = files.filter((file) => !file.startsWith(".") && file.slice(-3) === ".js"); - // unhidden .js files - const scripts = files.filter((file) => { - return file.substr(0, 1) !== "." && file.slice(-3) === ".js"; - }); - info(`WoT-Servient using current directory with ${scripts.length} script${scripts.length > 1 ? "s" : ""}`); + info(`WoT-Servient using current directory with %d script${scripts.length > 1 ? "s" : ""}`, scripts.length); - runScripts( - servient, - scripts.map((filename) => path.resolve(path.join(baseDir, filename))), - debug - ); - }); -}; + return runScripts(servient, args, scriptOptions, options.inspect ?? options.inspectBrk); +}); -buildConfig() - .then((conf) => { - return new DefaultServient(options.clientOnly, conf); - }) - .catch((err) => { - if (err.code === "ENOENT" && options.configFile == null) { - warn(`WoT-Servient using defaults as '${defaultFile}' does not exist`); - return new DefaultServient(options.clientOnly); - } else { - error(`"WoT-Servient config file error. ${err}`); - process.exit(err.errno); - } - }) - .then((servient) => { - servient - .start() - .then(() => { - if (args.length > 0) { - info(`WoT-Servient loading ${args.length} command line script${args.length > 1 ? "s" : ""}`); - return runScripts(servient, args, options.inspect ?? options.inspectBrk); - } else { - return runAllScripts(servient, options.inspect ?? options.inspectBrk); - } - }) - .catch((err) => { - error(`WoT-Servient cannot start. ${err}`); - }); - }) - .catch((err) => error(`WoT-Servient main error. ${err}`)); +program.parse(process.argv); diff --git a/packages/cli/src/config-builder.ts b/packages/cli/src/config-builder.ts new file mode 100644 index 000000000..5d59b698a --- /dev/null +++ b/packages/cli/src/config-builder.ts @@ -0,0 +1,42 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import * as fs from "fs"; +import _ from "lodash"; +import { DotenvParseOutput } from "dotenv"; + +export async function buildConfig( + options: Record, + defaultFile: string, + dotEnvConfigParameters: DotenvParseOutput +): Promise> { + let fileToOpen = defaultFile; + + if (typeof options.configFile === "string") { + fileToOpen = options.configFile; + } + + let configFileData = JSON.parse(await fs.promises.readFile(fileToOpen, "utf-8")); + + for (const [key, value] of Object.entries(dotEnvConfigParameters)) { + const obj = _.set({}, key, value); + configFileData = _.merge(configFileData, obj); + } + + if (options?.configParams != null) { + configFileData = _.merge(configFileData, options.configParams); + } + + return configFileData; +} diff --git a/packages/cli/src/parsers/config-file-parser.ts b/packages/cli/src/parsers/config-file-parser.ts new file mode 100644 index 000000000..ce7fe6be0 --- /dev/null +++ b/packages/cli/src/parsers/config-file-parser.ts @@ -0,0 +1,35 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { ErrorObject, ValidateFunction } from "ajv"; +import { InvalidArgumentError } from "commander"; +import { readFileSync } from "fs"; + +export function parseConfigFile(filename: string, previous: unknown, validator: ValidateFunction) { + try { + const open = filename; + const data = readFileSync(open, "utf-8"); + if (!validator(JSON.parse(data))) { + throw new InvalidArgumentError( + `Config file contains invalid an JSON: ${(validator.errors ?? []) + .map((o: ErrorObject) => o.message) + .join("\n")}` + ); + } + return filename; + } catch (err) { + throw new InvalidArgumentError(`Error reading config file: ${err}`); + } +} diff --git a/packages/cli/src/parsers/config-params-parser.ts b/packages/cli/src/parsers/config-params-parser.ts new file mode 100644 index 000000000..0448b9e10 --- /dev/null +++ b/packages/cli/src/parsers/config-params-parser.ts @@ -0,0 +1,48 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { ErrorObject, ValidateFunction } from "ajv"; +import { InvalidArgumentError } from "commander"; +import _ from "lodash"; + +export function parseConfigParams(param: string, previous: unknown, validator: ValidateFunction) { + // Validate key-value pair + if (!/^([a-zA-Z0-9_.]+):=([a-zA-Z0-9_]+)$/.test(param)) { + throw new InvalidArgumentError("Invalid key-value pair"); + } + const fieldNamePath = param.split(":=")[0]; + const fieldNameValue = param.split(":=")[1]; + let fieldNameValueCast; + if (Number(fieldNameValue)) { + fieldNameValueCast = +fieldNameValue; + } else if (fieldNameValue === "true" || fieldNameValue === "false") { + fieldNameValueCast = Boolean(fieldNameValue); + } else { + fieldNameValueCast = fieldNamePath; + } + + // Build object using dot-notation JSON path + const obj = _.set({}, fieldNamePath, fieldNameValueCast); + if (!validator(obj)) { + throw new InvalidArgumentError( + `Config parameter '${param}' is not valid: ${(validator.errors ?? []) + .map((o: ErrorObject) => o.message) + .join("\n")}` + ); + } + // Concatenate validated parameters + let result = previous ?? {}; + result = _.merge(result, obj); + return result; +} diff --git a/packages/cli/src/parsers/index.ts b/packages/cli/src/parsers/index.ts new file mode 100644 index 000000000..236c2aae3 --- /dev/null +++ b/packages/cli/src/parsers/index.ts @@ -0,0 +1,17 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +export * from "./config-file-parser"; +export * from "./config-params-parser"; +export * from "./ip-parser"; diff --git a/packages/cli/src/parsers/ip-parser.ts b/packages/cli/src/parsers/ip-parser.ts new file mode 100644 index 000000000..8c05479b7 --- /dev/null +++ b/packages/cli/src/parsers/ip-parser.ts @@ -0,0 +1,23 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { InvalidArgumentError } from "commander"; + +export function parseIp(value: string, previous: string) { + if (!/^([a-z]*|[\d.]*)(:[0-9]{2,5})?$/.test(value)) { + throw new InvalidArgumentError("Invalid host:port combo"); + } + + return value; +} diff --git a/packages/cli/src/script-runner.ts b/packages/cli/src/script-runner.ts new file mode 100644 index 000000000..e6d55586e --- /dev/null +++ b/packages/cli/src/script-runner.ts @@ -0,0 +1,88 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { createLoggers } from "@node-wot/core"; +import DefaultServient, { ScriptOptions } from "./cli-default-servient"; +import inspector from "inspector"; +import path from "path"; +import { readFile } from "fs/promises"; + +const { error, info, warn } = createLoggers("cli", "cli", "script-runner"); + +export interface DebugParams { + shouldBreak: boolean; + host: string; + port: number; +} +export async function runScripts( + servient: DefaultServient, + scripts: string[], + options: ScriptOptions, + debug?: DebugParams +) { + const launchScripts = (scripts: Array) => { + scripts.forEach(async (fname: string) => { + info(`WoT-Servient reading script ${fname}`); + try { + const data = await readFile(fname, "utf-8"); + // limit printout to first line + info( + `WoT-Servient running script '${data.substr(0, data.indexOf("\n")).replace("\r", "")}'... (${ + data.split(/\r\n|\r|\n/).length + } lines)` + ); + + fname = path.resolve(fname); + servient.runScript(data, fname, options); + } catch (err) { + error(`WoT-Servient experienced error while reading script. ${err}`); + } + }); + }; + // eslint-disable-next-line @typescript-eslint/no-var-requires + if (debug && debug.shouldBreak) { + // Activate inspector only if is not already opened and wait for the debugger to attach + inspector.url() == null && inspector.open(debug.port, debug.host, true); + + // Set a breakpoint at the first line of of first script + // the breakpoint gives time to inspector clients to set their breakpoints + const session = new inspector.Session(); + session.connect(); + session.post("Debugger.enable", (error: Error) => { + if (error != null) { + warn("Cannot set breakpoint; reason: cannot enable debugger"); + warn(error.toString()); + } + + session.post( + "Debugger.setBreakpointByUrl", + { + lineNumber: 0, + url: "file:///" + path.resolve(scripts[0]).replace(/\\/g, "/"), + }, + (err: Error | null) => { + if (err != null) { + warn("Cannot set breakpoint"); + warn(error.toString()); + } + launchScripts(scripts); + } + ); + }); + } else { + // Activate inspector only if is not already opened and don't wait + debug != null && inspector.url() == null && inspector.open(debug.port, debug.host, false); + launchScripts(scripts); + } +} diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts new file mode 100644 index 000000000..68feb5ca5 --- /dev/null +++ b/packages/cli/src/utils/index.ts @@ -0,0 +1,16 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +export * from "./load-env-variables"; +export * from "./load-compiler"; diff --git a/packages/cli/src/utils/load-compiler.ts b/packages/cli/src/utils/load-compiler.ts new file mode 100644 index 000000000..a4468aada --- /dev/null +++ b/packages/cli/src/utils/load-compiler.ts @@ -0,0 +1,35 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { CompilerFunction } from "../compiler-function"; + +export function loadCompiler(compilerModule?: string): CompilerFunction { + if (compilerModule != null) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const compilerMod = require(compilerModule); + + if (compilerMod.create == null) { + throw new Error(`No create function defined for ${compilerModule}`); + } + + const compilerObject = compilerMod.create(); + + if (compilerObject.compile == null) { + throw new Error("No compile function defined for create return object"); + } + + return compilerObject.compile; + } + return (code) => code; +} diff --git a/packages/cli/src/utils/load-env-variables.ts b/packages/cli/src/utils/load-env-variables.ts new file mode 100644 index 000000000..dcbbafc1a --- /dev/null +++ b/packages/cli/src/utils/load-env-variables.ts @@ -0,0 +1,26 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import * as dotenv from "dotenv"; +import ErrnoException = NodeJS.ErrnoException; + +export function loadEnvVariables() { + const env: dotenv.DotenvConfigOutput = dotenv.config(); + const errorNoException: ErrnoException | undefined = env.error; + // ignore file not found but throw otherwise + if (errorNoException?.code !== "ENOENT") { + throw env.error; + } + return env.parsed ?? {}; +} From e493e360fc99a2dac78037b88205fd4f8c576ad7 Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 29 Jan 2025 15:54:56 +0100 Subject: [PATCH 04/21] refactor(cli): organize logging configuration As a result the responsibility of managing the initial configuration for logging has been moved outside the DefaultServient. --- packages/cli/src/cli-default-servient.ts | 82 +++--------------------- packages/cli/src/cli.ts | 18 ++++-- packages/cli/src/utils/set-log-level.ts | 35 ++++++++++ 3 files changed, 57 insertions(+), 78 deletions(-) create mode 100644 packages/cli/src/utils/set-log-level.ts diff --git a/packages/cli/src/cli-default-servient.ts b/packages/cli/src/cli-default-servient.ts index ec4e14d2f..2f35185e6 100644 --- a/packages/cli/src/cli-default-servient.ts +++ b/packages/cli/src/cli-default-servient.ts @@ -28,6 +28,7 @@ import { FileClientFactory } from "@node-wot/binding-file"; import { ThingModelHelpers } from "@thingweb/thing-model"; import { createContext, Script } from "vm"; import { CompilerFunction } from "./compiler-function"; +import { LogLevel, setLogLevel } from "./utils/set-log-level"; const { debug, error, info } = createLoggers("cli", "cli-default-servient"); @@ -77,9 +78,6 @@ export default class DefaultServient extends Servient { coap: { port: 5683, }, - log: { - level: "info", - }, }; private uncaughtListeners: Array = []; @@ -103,9 +101,6 @@ export default class DefaultServient extends Servient { this.config.servient.clientOnly = true; } - // set log level before any output - this.setLogLevel(this.config.log.level); - // load credentials from config this.addCredentials(this.config.credentials); @@ -249,7 +244,10 @@ export default class DefaultServient extends Servient { actions: { setLogLevel: { description: "Set log level", - input: { oneOf: [{ type: "string" }, { type: "number" }] }, + input: { + type: "string", + enum: ["debug", "info", "warn", "error"], + }, output: { type: "string" }, }, shutdown: { @@ -268,16 +266,10 @@ export default class DefaultServient extends Servient { }, }); - servientProducedThing.setActionHandler("setLogLevel", async (level) => { - const ll = await Helpers.parseInteractionOutput(level); - if (typeof ll === "number") { - this.setLogLevel(ll as number); - } else if (typeof ll === "string") { - this.setLogLevel(ll as string); - } else { - // try to convert it to strings - this.setLogLevel(ll + ""); - } + servientProducedThing.setActionHandler("setLogLevel", async (payload) => { + const level = (await Helpers.parseInteractionOutput(payload)) as LogLevel; + setLogLevel(level); + this.logLevel = level; return `Log level set to '${this.logLevel}'`; }); servientProducedThing.setActionHandler("shutdown", async () => { @@ -310,60 +302,4 @@ export default class DefaultServient extends Servient { process.removeListener("uncaughtException", listener); }); } - - // Save default loggers (needed when changing log levels) - private readonly loggers: any = { - warn: console.warn, - info: console.info, - debug: console.debug, - }; - - private setLogLevel(logLevel: string | number): void { - if (logLevel === "error" || logLevel === 0) { - console.warn = () => { - /* nothing */ - }; - console.info = () => { - /* nothing */ - }; - console.debug = () => { - /* nothing */ - }; - - this.logLevel = "error"; - } else if (logLevel === "warn" || logLevel === "warning" || logLevel === 1) { - console.warn = this.loggers.warn; - console.info = () => { - /* nothing */ - }; - console.debug = () => { - /* nothing */ - }; - - this.logLevel = "warn"; - } else if (logLevel === "info" || logLevel === 2) { - console.warn = this.loggers.warn; - console.info = this.loggers.info; - console.debug = () => { - /* nothing */ - }; - - this.logLevel = "info"; - } else if (logLevel === "debug" || logLevel === 3) { - console.warn = this.loggers.warn; - console.info = this.loggers.info; - console.debug = this.loggers.debug; - - this.logLevel = "debug"; - } else { - // Fallback to default ("info") - console.warn = this.loggers.warn; - console.info = this.loggers.info; - console.debug = () => { - /* nothing */ - }; - - this.logLevel = "info"; - } - } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 123dbae6c..81e9f1268 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -18,7 +18,7 @@ import DefaultServient, { ScriptOptions } from "./cli-default-servient"; // tools import * as path from "path"; -import { Command, Argument } from "commander"; +import { Command, Argument, Option } from "commander"; import Ajv, { ValidateFunction } from "ajv"; import ConfigSchema from "./wot-servient-schema.conf.json"; import { version } from "@node-wot/core/package.json"; @@ -27,8 +27,8 @@ import { buildConfig } from "./config-builder"; import { loadCompiler, loadEnvVariables } from "./utils"; import { runScripts } from "./script-runner"; import { readdir } from "fs/promises"; -import * as logger from "debug"; import { parseConfigFile, parseConfigParams, parseIp } from "./parsers"; +import { setLogLevel } from "./utils/set-log-level"; const { error, info, warn, debug } = createLoggers("cli", "cli"); @@ -120,6 +120,12 @@ program .option("-ib, --inspect-brk [host]:[port]", "activate inspector on host:port (default: 127.0.0.1:9229)", parseIp) .option("-c, --client-only", "do not start any servers (enables multiple instances without port conflicts)") .option("-cp, --compiler ", "load module as a compiler") + .addOption( + new Option( + "-ll, --logLevel ", + "choose the desired log level. WARNING: if DEBUG env variable is specified this option gets overridden." + ).choices(["debug", "info", "warn", "error"]) + ) .option("-f, --config-file ", "load configuration from specified file", (value, previous) => parseConfigFile(value, previous, schemaValidator) ) @@ -138,11 +144,11 @@ program.addArgument( ); program.action(async function (_, options, cmd) { + // Allow user to personalized the env if (process.env.DEBUG == null) { // by default enable error logs and warnings - // user can override it using DEBUG env - logger.enable("node-wot:**:error"); - logger.enable("node-wot:**:warn"); + // user can override using command line option + setLogLevel(options.logLevel ?? "warn"); } const args = cmd.args; @@ -156,6 +162,8 @@ program.action(async function (_, options, cmd) { try { const config = await buildConfig(options, defaultFilePath, env); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setLogLevel((config.log as any).level ?? options.logLevel ?? "warn"); servient = new DefaultServient(options.clientOnly, config); } catch (err) { if ((err as NodeJS.ErrnoException)?.code !== "ENOENT" || options.configFile != null) { diff --git a/packages/cli/src/utils/set-log-level.ts b/packages/cli/src/utils/set-log-level.ts new file mode 100644 index 000000000..628aec3dd --- /dev/null +++ b/packages/cli/src/utils/set-log-level.ts @@ -0,0 +1,35 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { createLoggers } from "@node-wot/core"; +import * as logger from "debug"; + +export type LogLevel = keyof ReturnType; +export function setLogLevel(level: LogLevel): void { + logger.disable(); + switch (level) { + case "debug": + logger.enable("node-wot:*"); + break; + case "info": + logger.enable("node-wot:**:error,node-wot:**:warn,node-wot:**:info"); + break; + case "warn": + logger.enable("node-wot:**:error,node-wot:**:warn"); + break; + case "error": + logger.enable("node-wot:**:error"); + break; + } +} From 6f5953cac716520284e4c81f33a2e48991537e04 Mon Sep 17 00:00:00 2001 From: reluc Date: Thu, 30 Jan 2025 15:13:06 +0100 Subject: [PATCH 05/21] refactor(cli/parsers/config-file-parser): better error messages --- packages/cli/src/parsers/config-file-parser.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/parsers/config-file-parser.ts b/packages/cli/src/parsers/config-file-parser.ts index ce7fe6be0..d3aef1934 100644 --- a/packages/cli/src/parsers/config-file-parser.ts +++ b/packages/cli/src/parsers/config-file-parser.ts @@ -23,13 +23,16 @@ export function parseConfigFile(filename: string, previous: unknown, validator: const data = readFileSync(open, "utf-8"); if (!validator(JSON.parse(data))) { throw new InvalidArgumentError( - `Config file contains invalid an JSON: ${(validator.errors ?? []) - .map((o: ErrorObject) => o.message) + `\n\nConfig file contains an invalid JSON structure:\n ${(validator.errors ?? []) + .map((o: ErrorObject) => `\tError ${o.instancePath || "root"}: ${o.message}`) .join("\n")}` ); } return filename; } catch (err) { - throw new InvalidArgumentError(`Error reading config file: ${err}`); + if (err instanceof InvalidArgumentError) { + throw err; + } + throw new InvalidArgumentError(`\nError reading config file: ${err}`); } } From 861bcadfcee1161bb4d7c1b6950ae26b115d2105 Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 5 Mar 2025 17:45:40 +0100 Subject: [PATCH 06/21] refactor(cli): improve configuration building and keep it sync with json schema --- package-lock.json | 23 +++- package.json | 2 +- packages/cli/.gitignore | 1 + packages/cli/eslint.config.mjs | 7 ++ packages/cli/import-json.js | 11 ++ packages/cli/package.json | 6 +- packages/cli/src/cli-default-servient.ts | 68 ++---------- packages/cli/src/cli.ts | 29 ++--- packages/cli/src/config-builder.ts | 42 -------- packages/cli/src/configuration.ts | 100 ++++++++++++++++++ .../cli/src/parsers/config-file-parser.ts | 11 +- .../cli/src/wot-servient-schema.conf.json | 44 +++----- packages/cli/test/.eslintrc.json | 6 -- packages/cli/tsconfig.json | 2 +- 14 files changed, 186 insertions(+), 166 deletions(-) create mode 100644 packages/cli/.gitignore create mode 100644 packages/cli/eslint.config.mjs create mode 100644 packages/cli/import-json.js delete mode 100644 packages/cli/src/config-builder.ts create mode 100644 packages/cli/src/configuration.ts delete mode 100644 packages/cli/test/.eslintrc.json diff --git a/package-lock.json b/package-lock.json index fbe0796eb..06e7e5a07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6582,10 +6582,18 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-schema-ref-parser/node_modules/sprintf-js": { - "version": "1.0.3", + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "dev": true, - "license": "BSD-3-Clause" + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -10927,6 +10935,12 @@ "node": ">=18" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true + }, "node_modules/ts-api-utils": { "version": "2.1.0", "dev": true, @@ -12465,7 +12479,8 @@ "wot-servient": "bin/index.js" }, "devDependencies": { - "@types/lodash": "^4.14.199" + "@types/lodash": "^4.14.199", + "json-schema-to-ts": "^3.1.1" }, "optionalDependencies": { "ts-node": "10.9.1" diff --git a/package.json b/package.json index a51ab1dc0..4a058c234 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ } }, "scripts": { - "build": "tsc -b && npm run build -w packages/browser-bundle", + "build": "npm run build:json -w packages/cli && tsc -b && npm run build -w packages/browser-bundle", "pretest": "npm run build", "start": "cd packages/cli && npm run start", "debug": "cd packages/cli && npm run debug", diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 000000000..c83f90a2b --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1 @@ +src/generated diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs new file mode 100644 index 000000000..14857d013 --- /dev/null +++ b/packages/cli/eslint.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import baseConfig from "../../eslint.config.mjs"; + +export default defineConfig([ + baseConfig, + globalIgnores(["src/generated/**.ts", "./import-json.js"]) +]) diff --git a/packages/cli/import-json.js b/packages/cli/import-json.js new file mode 100644 index 000000000..3186dc0f3 --- /dev/null +++ b/packages/cli/import-json.js @@ -0,0 +1,11 @@ +const { readFileSync, writeFileSync } = require("fs"); + +const schema = readFileSync("./src/wot-servient-schema.conf.json", "utf8"); +const package = readFileSync("./package.json", "utf8"); +const { version } = JSON.parse(package); + +writeFileSync( + "./src/generated/wot-servient-schema.conf.ts", + `const schema = ${schema.trimEnd()} as const \nexport default schema;` +); +writeFileSync("./src/generated/version.ts", `const version = "${version}" as const \nexport default version;`); diff --git a/packages/cli/package.json b/packages/cli/package.json index b12e3959e..6141302b5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -33,7 +33,8 @@ "lodash": "^4.17.21" }, "scripts": { - "build": "tsc -b", + "build:json": "node import-json.js", + "build": "npm run build:json && tsc -b", "start": "ts-node src/cli.ts", "debug": "node -r ts-node/register --inspect-brk=9229 src/cli.ts", "lint": "eslint .", @@ -47,6 +48,7 @@ "homepage": "https://github.com/eclipse-thingweb/node-wot/tree/master/packages/cli#readme", "keywords": [], "devDependencies": { - "@types/lodash": "^4.14.199" + "@types/lodash": "^4.14.199", + "json-schema-to-ts": "^3.1.1" } } diff --git a/packages/cli/src/cli-default-servient.ts b/packages/cli/src/cli-default-servient.ts index 2f35185e6..5770e2bf5 100644 --- a/packages/cli/src/cli-default-servient.ts +++ b/packages/cli/src/cli-default-servient.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ /******************************************************************************** * Copyright (c) 2018 Contributors to the Eclipse Foundation * @@ -29,89 +28,34 @@ import { ThingModelHelpers } from "@thingweb/thing-model"; import { createContext, Script } from "vm"; import { CompilerFunction } from "./compiler-function"; import { LogLevel, setLogLevel } from "./utils/set-log-level"; +import { ConfigurationAfterDefaults } from "./configuration"; const { debug, error, info } = createLoggers("cli", "cli-default-servient"); -// Helper function needed for `mergeConfigs` function -function isObject(item: unknown) { - return item != null && typeof item === "object" && !Array.isArray(item); -} - -/** - * Helper function merging default parameters into a custom config file. - * - * @param {object} target - an object containing default config parameters - * @param {object} source - an object containing custom config parameters - * - * @return {object} The new config file containing both custom and default parameters - */ -function mergeConfigs(target: any, source: any): any { - const output = Object.assign({}, target); - Object.keys(source).forEach((key) => { - if (!(key in target)) { - Object.assign(output, { [key]: source[key] }); - } else { - if (isObject(target[key]) && isObject(source[key])) { - output[key] = mergeConfigs(target[key], source[key]); - } else { - Object.assign(output, { [key]: source[key] }); - } - } - }); - return output; -} export interface ScriptOptions { argv?: Array; compiler?: CompilerFunction; env?: Record; } export default class DefaultServient extends Servient { - private static readonly defaultConfig = { - servient: { - clientOnly: false, - scriptAction: false, - }, - http: { - port: 8080, - allowSelfSigned: false, - }, - coap: { - port: 5683, - }, - }; - private uncaughtListeners: Array = []; private runtime: typeof WoT | undefined; - public readonly config: any; + public readonly config: ConfigurationAfterDefaults; // current log level public logLevel = "info"; - public constructor(clientOnly: boolean, config?: any) { + public constructor(config: ConfigurationAfterDefaults) { super(); - // init config - this.config = - typeof config === "object" - ? mergeConfigs(DefaultServient.defaultConfig, config) - : DefaultServient.defaultConfig; - - // apply flags - if (clientOnly) { - this.config.servient ??= {}; - this.config.servient.clientOnly = true; - } + this.config = config; // load credentials from config this.addCredentials(this.config.credentials); - // remove secrets from original for displaying config (already added) - if (this.config.credentials != null) { - delete this.config.credentials; - } - // display debug("DefaultServient configured with"); - debug(`${this.config}`); + // remove secrets from original for displaying config + debug(`%O`, { ...this.config, credentials: null }); // apply config if (typeof this.config.servient.staticAddress === "string") { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 81e9f1268..458dbde26 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -20,21 +20,22 @@ import DefaultServient, { ScriptOptions } from "./cli-default-servient"; import * as path from "path"; import { Command, Argument, Option } from "commander"; import Ajv, { ValidateFunction } from "ajv"; -import ConfigSchema from "./wot-servient-schema.conf.json"; -import { version } from "@node-wot/core/package.json"; +import ConfigSchema from "./generated/wot-servient-schema.conf"; +import version from "./generated/version"; import { createLoggers } from "@node-wot/core"; -import { buildConfig } from "./config-builder"; import { loadCompiler, loadEnvVariables } from "./utils"; import { runScripts } from "./script-runner"; import { readdir } from "fs/promises"; import { parseConfigFile, parseConfigParams, parseIp } from "./parsers"; import { setLogLevel } from "./utils/set-log-level"; +import { buildConfig, buildConfigFromFile, Configuration, defaultConfiguration } from "./configuration"; +import { cloneDeep } from "lodash"; const { error, info, warn, debug } = createLoggers("cli", "cli"); const program = new Command(); -const ajv = new Ajv({ strict: true }); -const schemaValidator = ajv.compile(ConfigSchema) as ValidateFunction; +const ajv = new Ajv({ strict: true, allErrors: true }); +const schemaValidator = ajv.compile(ConfigSchema) as ValidateFunction; const defaultFile = "wot-servient.conf.json"; const baseDir = "."; @@ -127,7 +128,7 @@ program ).choices(["debug", "info", "warn", "error"]) ) .option("-f, --config-file ", "load configuration from specified file", (value, previous) => - parseConfigFile(value, previous, schemaValidator) + parseConfigFile(value, previous) ) .option( "-p, --config-params ", @@ -148,6 +149,7 @@ program.action(async function (_, options, cmd) { if (process.env.DEBUG == null) { // by default enable error logs and warnings // user can override using command line option + // or later by config file. setLogLevel(options.logLevel ?? "warn"); } @@ -161,18 +163,21 @@ program.action(async function (_, options, cmd) { debug("command line environment variables", args); try { - const config = await buildConfig(options, defaultFilePath, env); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setLogLevel((config.log as any).level ?? options.logLevel ?? "warn"); - servient = new DefaultServient(options.clientOnly, config); + const config = await buildConfigFromFile(options, defaultFilePath, env, schemaValidator); + setLogLevel(options.logLevel ?? config.logLevel); + config.servient.clientOnly = options.clientOnly ?? config.servient.clientOnly; + servient = new DefaultServient(config); } catch (err) { if ((err as NodeJS.ErrnoException)?.code !== "ENOENT" || options.configFile != null) { - error("WoT-Servient config file error. %O", err); + error("WoT-Servient configuration file error:\n%O\nClose.", err); process.exit((err as NodeJS.ErrnoException).errno ?? 1); } warn(`WoT-Servient using defaults as %s does not exist`, defaultFile); - servient = new DefaultServient(options.clientOnly); + + const config = await buildConfig(options, cloneDeep(defaultConfiguration), env, schemaValidator); + config.servient.clientOnly = options.clientOnly ?? config.servient.clientOnly; + servient = new DefaultServient(config); } await servient.start(); diff --git a/packages/cli/src/config-builder.ts b/packages/cli/src/config-builder.ts deleted file mode 100644 index 5d59b698a..000000000 --- a/packages/cli/src/config-builder.ts +++ /dev/null @@ -1,42 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2025 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and - * Document License (2015-05-13) which is available at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. - * - * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ -import * as fs from "fs"; -import _ from "lodash"; -import { DotenvParseOutput } from "dotenv"; - -export async function buildConfig( - options: Record, - defaultFile: string, - dotEnvConfigParameters: DotenvParseOutput -): Promise> { - let fileToOpen = defaultFile; - - if (typeof options.configFile === "string") { - fileToOpen = options.configFile; - } - - let configFileData = JSON.parse(await fs.promises.readFile(fileToOpen, "utf-8")); - - for (const [key, value] of Object.entries(dotEnvConfigParameters)) { - const obj = _.set({}, key, value); - configFileData = _.merge(configFileData, obj); - } - - if (options?.configParams != null) { - configFileData = _.merge(configFileData, options.configParams); - } - - return configFileData; -} diff --git a/packages/cli/src/configuration.ts b/packages/cli/src/configuration.ts new file mode 100644 index 000000000..a95f5b5c2 --- /dev/null +++ b/packages/cli/src/configuration.ts @@ -0,0 +1,100 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { FromSchema } from "json-schema-to-ts"; +import schema from "./generated/wot-servient-schema.conf"; +import { DotenvParseOutput } from "dotenv"; +import _ from "lodash"; +import { readFile } from "fs/promises"; +import { ValidateFunction, ValidationError } from "ajv"; + +type Merge = { [K in keyof T as K extends keyof U ? never : K]: T[K] } & { + [L in keyof U & keyof T]: Merge; +} & { [J in keyof U as J extends keyof T ? never : J]: U[J] }; + +type Mutable = { -readonly [K in keyof T]: Mutable }; +type Generalize = T extends number + ? number + : T extends string + ? string + : T extends boolean + ? boolean + : T extends Array + ? Array> + : T extends object + ? { [K in keyof T]: Generalize } + : T; +export type Configuration = FromSchema; + +export const defaultConfiguration = Object.freeze({ + servient: { + clientOnly: false, + scriptAction: false, + }, + http: { + port: 8080, + allowSelfSigned: false, + }, + coap: { + port: 5683, + }, + credentials: {}, + logLevel: "warn", +} as const satisfies Configuration); + +export type ConfigurationAfterDefaults = Merge>>; + +export async function buildConfig( + options: Record, + configuration: Configuration, + dotEnvConfigParameters: DotenvParseOutput, + validator: ValidateFunction +): Promise { + let config = configuration; + + for (const [key, value] of Object.entries(dotEnvConfigParameters)) { + const obj = _.set({}, key, value); + config = _.merge(config, obj); + } + + if (options?.configParams != null) { + config = _.merge(config, options.configParams); + } + + config = _.merge({}, defaultConfiguration, config); + + if (!validator(config)) { + throw new ValidationError(validator.errors ?? []); + } + + return config as ConfigurationAfterDefaults; +} + +export async function buildConfigFromFile( + options: Record, + defaultFile: string, + dotEnvConfigParameters: DotenvParseOutput, + validator: ValidateFunction +): Promise { + let fileToOpen = defaultFile; + + if (typeof options.configFile === "string") { + fileToOpen = options.configFile; + } + + const configFileData = JSON.parse(await readFile(fileToOpen, "utf-8")); + + return buildConfig(options, configFileData, dotEnvConfigParameters, validator); +} diff --git a/packages/cli/src/parsers/config-file-parser.ts b/packages/cli/src/parsers/config-file-parser.ts index d3aef1934..b64644acb 100644 --- a/packages/cli/src/parsers/config-file-parser.ts +++ b/packages/cli/src/parsers/config-file-parser.ts @@ -13,21 +13,14 @@ * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ -import { ErrorObject, ValidateFunction } from "ajv"; import { InvalidArgumentError } from "commander"; import { readFileSync } from "fs"; -export function parseConfigFile(filename: string, previous: unknown, validator: ValidateFunction) { +export function parseConfigFile(filename: string, previous: unknown) { try { const open = filename; const data = readFileSync(open, "utf-8"); - if (!validator(JSON.parse(data))) { - throw new InvalidArgumentError( - `\n\nConfig file contains an invalid JSON structure:\n ${(validator.errors ?? []) - .map((o: ErrorObject) => `\tError ${o.instancePath || "root"}: ${o.message}`) - .join("\n")}` - ); - } + JSON.parse(data); return filename; } catch (err) { if (err instanceof InvalidArgumentError) { diff --git a/packages/cli/src/wot-servient-schema.conf.json b/packages/cli/src/wot-servient-schema.conf.json index 95123eb04..30376ebfa 100644 --- a/packages/cli/src/wot-servient-schema.conf.json +++ b/packages/cli/src/wot-servient-schema.conf.json @@ -3,6 +3,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { + "$schema": { "type": "string" }, "servient": { "type": "object", "properties": { @@ -17,7 +18,8 @@ "type": "boolean", "default": false } - } + }, + "additionalProperties": false }, "http": { "type": "object", @@ -54,7 +56,8 @@ "password": { "type": "string" } - } + }, + "additionalProperties": false }, "allowSelfSigned": { "type": "boolean" @@ -65,7 +68,8 @@ "serverCert": { "type": "string" } - } + }, + "additionalProperties": false }, "mqtt": { "type": "object", @@ -84,10 +88,11 @@ }, "protocolVersion": { "type": "integer", - "examples": [3, 4, 5], + "enum": [3, 4, 5], "default": 5 } - } + }, + "additionalProperties": false }, "coap": { "type": "object", @@ -95,7 +100,8 @@ "port": { "type": "integer" } - } + }, + "additionalProperties": false }, "credentials": { "type": "object", @@ -114,28 +120,12 @@ } } } - } + }, + "additionalProperties": false }, - "log": { - "type": "object", - "oneOf": [ - { - "properties": { - "level": { - "type": "integer", - "enum": [0, 1, 2, 3] - } - } - }, - { - "properties": { - "level": { - "type": "string", - "enum": ["debug", "info", "warn", "error"] - } - } - } - ] + "logLevel": { + "type": "string", + "enum": ["debug", "info", "warn", "error"] } }, "additionalProperties": false diff --git a/packages/cli/test/.eslintrc.json b/packages/cli/test/.eslintrc.json deleted file mode 100644 index 53d0343f1..000000000 --- a/packages/cli/test/.eslintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../.eslintrc.json", - "rules": { - "import/no-extraneous-dependencies": "off" - } -} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index ffcc38f9d..6447e8437 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -3,9 +3,9 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", - "resolveJsonModule": true, "esModuleInterop": true }, + "exclude": ["eslint.config.mjs"], "include": ["src/**/*", "src/*.json"], "references": [ { "path": "../core" }, From 7bb51f3ee40f2ac7f27d735a6b8ab5c71b8b3e2a Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 5 Mar 2025 17:46:22 +0100 Subject: [PATCH 07/21] fix(mqtt): allow optional uri in MQTT Server configuration --- packages/binding-mqtt/src/mqtt-broker-server.ts | 15 ++++++++++----- packages/binding-mqtt/src/mqtt.ts | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/binding-mqtt/src/mqtt-broker-server.ts b/packages/binding-mqtt/src/mqtt-broker-server.ts index 810f3fddc..ba1ffd59c 100644 --- a/packages/binding-mqtt/src/mqtt-broker-server.ts +++ b/packages/binding-mqtt/src/mqtt-broker-server.ts @@ -65,7 +65,7 @@ export default class MqttBrokerServer implements ProtocolServer { private port = -1; private address?: string = undefined; - private brokerURI: string; + private brokerURI?: string; private readonly things: Map = new Map(); @@ -77,15 +77,14 @@ export default class MqttBrokerServer implements ProtocolServer { private hostedBroker?: net.Server; constructor(config: MqttBrokerServerConfig) { - this.config = config ?? this.defaults; - this.config.uri = this.config.uri ?? this.defaults.uri; - // if there is a MQTT protocol indicator missing, add this - if (config.uri.indexOf("://") === -1) { + if (config.uri?.indexOf("://") === -1) { config.uri = this.scheme + "://" + config.uri; } this.brokerURI = config.uri; + this.config = config ?? this.defaults; + this.config.uri = this.config.uri ?? this.defaults.uri; } public async expose(thing: ExposedThing): Promise { @@ -446,6 +445,12 @@ export default class MqttBrokerServer implements ProtocolServer { private async startBroker() { return new Promise((resolve, reject) => { + console.log("here mf"); + + if (this.brokerURI == null) { + throw new Error("Unexpected configuration state broker was started even if brokerURI is null"); + } + this.hostedServer = Server({}); let server: tls.Server | net.Server; if (this.config.key) { diff --git a/packages/binding-mqtt/src/mqtt.ts b/packages/binding-mqtt/src/mqtt.ts index 9df00c9a1..cccfac80e 100644 --- a/packages/binding-mqtt/src/mqtt.ts +++ b/packages/binding-mqtt/src/mqtt.ts @@ -51,7 +51,7 @@ export interface MqttClientConfig { } export interface MqttBrokerServerConfig { - uri: string; + uri?: string; user?: string; psw?: string; clientId?: string; From f49662fa7d047e8cfb3dd305873665923ab0b3c2 Mon Sep 17 00:00:00 2001 From: reluc Date: Fri, 27 Jun 2025 13:13:11 +0200 Subject: [PATCH 08/21] feat(cli): simplify CLI script execution WARNING: Drops support for running remote scripts from the Default Servient Thing. This feature was rarely used and controversial due to security implications. Users wanting to run remote scripts can easily implement this feature in their own Servient by extending the CLI Servient. --- .../binding-mqtt/src/mqtt-broker-server.ts | 2 - packages/cli/import-json.js | 6 ++ packages/cli/src/cli-default-servient.ts | 97 +------------------ packages/cli/src/cli.ts | 19 ++-- packages/cli/src/executor.ts | 50 ++++++++++ packages/cli/src/script-runner.ts | 14 +-- packages/cli/src/utils/index.ts | 2 +- packages/examples/src/scripts/counter.ts | 1 - packages/examples/tsconfig.json | 2 +- .../compiler-function.ts => test/relative.ts | 5 +- .../utils/load-compiler.ts => test/test.ts | 24 +---- 11 files changed, 81 insertions(+), 141 deletions(-) create mode 100644 packages/cli/src/executor.ts rename packages/cli/src/compiler-function.ts => test/relative.ts (90%) rename packages/cli/src/utils/load-compiler.ts => test/test.ts (52%) diff --git a/packages/binding-mqtt/src/mqtt-broker-server.ts b/packages/binding-mqtt/src/mqtt-broker-server.ts index ba1ffd59c..0a1335f41 100644 --- a/packages/binding-mqtt/src/mqtt-broker-server.ts +++ b/packages/binding-mqtt/src/mqtt-broker-server.ts @@ -445,8 +445,6 @@ export default class MqttBrokerServer implements ProtocolServer { private async startBroker() { return new Promise((resolve, reject) => { - console.log("here mf"); - if (this.brokerURI == null) { throw new Error("Unexpected configuration state broker was started even if brokerURI is null"); } diff --git a/packages/cli/import-json.js b/packages/cli/import-json.js index 3186dc0f3..20857da9b 100644 --- a/packages/cli/import-json.js +++ b/packages/cli/import-json.js @@ -1,9 +1,15 @@ const { readFileSync, writeFileSync } = require("fs"); +const { existsSync, mkdirSync } = require("fs"); const schema = readFileSync("./src/wot-servient-schema.conf.json", "utf8"); const package = readFileSync("./package.json", "utf8"); const { version } = JSON.parse(package); +const generatedDir = "./src/generated"; +if (!existsSync(generatedDir)) { + mkdirSync(generatedDir, { recursive: true }); +} + writeFileSync( "./src/generated/wot-servient-schema.conf.ts", `const schema = ${schema.trimEnd()} as const \nexport default schema;` diff --git a/packages/cli/src/cli-default-servient.ts b/packages/cli/src/cli-default-servient.ts index 5770e2bf5..997c68182 100644 --- a/packages/cli/src/cli-default-servient.ts +++ b/packages/cli/src/cli-default-servient.ts @@ -24,22 +24,13 @@ import { HttpServer, HttpClientFactory, HttpsClientFactory } from "@node-wot/bin import { CoapServer, CoapClientFactory, CoapsClientFactory } from "@node-wot/binding-coap"; import { MqttBrokerServer, MqttClientFactory } from "@node-wot/binding-mqtt"; import { FileClientFactory } from "@node-wot/binding-file"; -import { ThingModelHelpers } from "@thingweb/thing-model"; -import { createContext, Script } from "vm"; -import { CompilerFunction } from "./compiler-function"; -import { LogLevel, setLogLevel } from "./utils/set-log-level"; +import { LogLevel, setLogLevel } from "./utils"; import { ConfigurationAfterDefaults } from "./configuration"; -const { debug, error, info } = createLoggers("cli", "cli-default-servient"); +const { debug, info } = createLoggers("cli", "cli-default-servient"); -export interface ScriptOptions { - argv?: Array; - compiler?: CompilerFunction; - env?: Record; -} export default class DefaultServient extends Servient { private uncaughtListeners: Array = []; - private runtime: typeof WoT | undefined; public readonly config: ConfigurationAfterDefaults; // current log level public logLevel = "info"; @@ -99,78 +90,11 @@ export default class DefaultServient extends Servient { this.addClientFactory(new MqttClientFactory()); } - /** - * Runs the script in privileged context (dangerous). In practice, this means that the script can - * require system modules. - * @param {string} code - the script to run - * @param {string} filename - the filename of the script - * @param {object} options - pass cli variables or envs to the script - */ - public runScript(code: string, filename = "script", options: ScriptOptions = {}): unknown { - if (!this.runtime) { - throw new Error("WoT runtime not loaded; have you called start()?"); - } - const helpers = new Helpers(this); - - options.compiler ??= (code) => code; - - const compiledCode = options.compiler(code, filename); - const script = new Script(compiledCode, filename); - process.argv = options.argv ?? []; - process.env = options.env ?? process.env; - const context = createContext({ - ...globalThis, - process, - require, - console, - exports: {}, - WoT: this.runtime, - WoTHelpers: helpers, - ModelHelpers: new ThingModelHelpers(helpers), - }); - - const listener = (err: Error) => { - this.logScriptError(`Asynchronous script error '${filename}'`, err); - // TODO: clean up script resources - process.exit(1); - }; - - process.prependListener("uncaughtException", listener); - this.uncaughtListeners.push(listener); - - try { - return script.runInContext(context, { displayErrors: true }); - } catch (err) { - if (err instanceof Error) { - this.logScriptError(`Servient found error in privileged script '${filename}'`, err); - } else { - error(`Servient found error in privileged script '${filename}' ${err}`); - } - return undefined; - } - } - - private logScriptError(description: string, err: Error): void { - let message: string; - if (typeof err === "object" && err.stack != null) { - const match = err.stack.match(/evalmachine\.:([0-9]+:[0-9]+)/); - if (Array.isArray(match)) { - message = `and halted at line ${match[1]}\n ${err}`; - } else { - message = `and halted with ${err.stack}`; - } - } else { - message = `that threw ${typeof err} instead of Error\n ${err}`; - } - error(`Servient caught ${description} ${message}`); - } - /** * start */ public async start(): Promise { const superWoT = await super.start(); - this.runtime = superWoT; info("DefaultServient started"); @@ -198,15 +122,6 @@ export default class DefaultServient extends Servient { description: "Stop servient", output: { type: "string" }, }, - ...(this.config.servient.scriptAction === true - ? { - runScript: { - description: "Run script", - input: { type: "string" }, - output: { type: "string" }, - }, - } - : {}), }, }); @@ -221,14 +136,6 @@ export default class DefaultServient extends Servient { await this.shutdown(); return undefined; }); - if (this.config.servient.scriptAction === true) { - servientProducedThing.setActionHandler("runScript", async (script) => { - const scriptv = await Helpers.parseInteractionOutput(script); - debug("running script", scriptv); - this.runScript(scriptv as string); - return undefined; - }); - } servientProducedThing.setPropertyReadHandler("things", async () => { debug("returning things"); return this.getThings(); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 458dbde26..f95623e13 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,7 +14,7 @@ ********************************************************************************/ // default implementation of W3C WoT Servient (http(s) and file bindings) -import DefaultServient, { ScriptOptions } from "./cli-default-servient"; +import DefaultServient from "./cli-default-servient"; // tools import * as path from "path"; @@ -22,8 +22,8 @@ import { Command, Argument, Option } from "commander"; import Ajv, { ValidateFunction } from "ajv"; import ConfigSchema from "./generated/wot-servient-schema.conf"; import version from "./generated/version"; -import { createLoggers } from "@node-wot/core"; -import { loadCompiler, loadEnvVariables } from "./utils"; +import { createLoggers, Helpers } from "@node-wot/core"; +import { loadEnvVariables } from "./utils"; import { runScripts } from "./script-runner"; import { readdir } from "fs/promises"; import { parseConfigFile, parseConfigParams, parseIp } from "./parsers"; @@ -180,16 +180,11 @@ program.action(async function (_, options, cmd) { servient = new DefaultServient(config); } - await servient.start(); - - const scriptOptions: ScriptOptions = { - env, - argv: args, - compiler: loadCompiler(options.compiler), - }; + const runtime = await servient.start(); + const helpers = new Helpers(servient); if (args.length > 0) { - return runScripts(servient, args, scriptOptions, options.inspect ?? options.inspectBrk); + return runScripts({ runtime, helpers }, args, options.inspect ?? options.inspectBrk); } const files = await readdir(baseDir); @@ -197,7 +192,7 @@ program.action(async function (_, options, cmd) { info(`WoT-Servient using current directory with %d script${scripts.length > 1 ? "s" : ""}`, scripts.length); - return runScripts(servient, args, scriptOptions, options.inspect ?? options.inspectBrk); + return runScripts({ runtime, helpers }, scripts, options.inspect ?? options.inspectBrk); }); program.parse(process.argv); diff --git a/packages/cli/src/executor.ts b/packages/cli/src/executor.ts new file mode 100644 index 000000000..f7abb433a --- /dev/null +++ b/packages/cli/src/executor.ts @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { createLoggers, Helpers } from "@node-wot/core"; +const { debug } = createLoggers("cli", "executor"); + +export interface WoTContext { + runtime: typeof WoT; + helpers: Helpers; +} + +export class Executor { + public async exec(file: string, wotContext: WoTContext): Promise { + debug(`Executing WoT script from file: ${file}`); + const userScriptPathArg = file; + const isTypeScriptScript = + userScriptPathArg && (userScriptPathArg.endsWith(".ts") || userScriptPathArg.endsWith(".tsx")); + global.WoT = wotContext.runtime; + + if (isTypeScriptScript === true) { + require("ts-node/register"); + } + + try { + // Execute the user's script + // Node.js will now handle .ts files automatically if ts-node is registered + // TODO: For ESM modules a more complex check might be needed. + if (file.endsWith(".mjs")) { + return await import(`file:///${file}`); + } else { + return require(file); + } + } catch (error) { + console.error("Error running WoT script:", error); + process.exit(1); + } + } +} diff --git a/packages/cli/src/script-runner.ts b/packages/cli/src/script-runner.ts index e6d55586e..ab651c3ef 100644 --- a/packages/cli/src/script-runner.ts +++ b/packages/cli/src/script-runner.ts @@ -13,10 +13,10 @@ * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ import { createLoggers } from "@node-wot/core"; -import DefaultServient, { ScriptOptions } from "./cli-default-servient"; import inspector from "inspector"; import path from "path"; import { readFile } from "fs/promises"; +import { Executor, WoTContext } from "./executor"; const { error, info, warn } = createLoggers("cli", "cli", "script-runner"); @@ -25,12 +25,8 @@ export interface DebugParams { host: string; port: number; } -export async function runScripts( - servient: DefaultServient, - scripts: string[], - options: ScriptOptions, - debug?: DebugParams -) { +export async function runScripts(context: WoTContext, scripts: string[], debug?: DebugParams) { + const executor = new Executor(); const launchScripts = (scripts: Array) => { scripts.forEach(async (fname: string) => { info(`WoT-Servient reading script ${fname}`); @@ -44,9 +40,9 @@ export async function runScripts( ); fname = path.resolve(fname); - servient.runScript(data, fname, options); + await executor.exec(fname, context); } catch (err) { - error(`WoT-Servient experienced error while reading script. ${err}`); + error(`WoT-Servient experienced error while reading script. %O`, err); } }); }; diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index 68feb5ca5..2dcb67164 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -13,4 +13,4 @@ * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ export * from "./load-env-variables"; -export * from "./load-compiler"; +export * from "./set-log-level"; diff --git a/packages/examples/src/scripts/counter.ts b/packages/examples/src/scripts/counter.ts index 55afa7c4e..9f9a11b29 100644 --- a/packages/examples/src/scripts/counter.ts +++ b/packages/examples/src/scripts/counter.ts @@ -23,7 +23,6 @@ // * multi-language // * image contentTypes for properties (Note: the contentType applies to all forms of the property) // * links with entry containing rel and sizes - let count: number; let lastChange: string; diff --git a/packages/examples/tsconfig.json b/packages/examples/tsconfig.json index 118676f68..77ff8d75f 100644 --- a/packages/examples/tsconfig.json +++ b/packages/examples/tsconfig.json @@ -8,6 +8,6 @@ "sourceMap": false, "removeComments": false }, - "include": ["src/**/*"], + "include": ["src/**/*", "../../node_modules/wot-typescript-definitions/**/*.d.ts"], "references": [] } diff --git a/packages/cli/src/compiler-function.ts b/test/relative.ts similarity index 90% rename from packages/cli/src/compiler-function.ts rename to test/relative.ts index ba56f0012..e1b0a72c0 100644 --- a/packages/cli/src/compiler-function.ts +++ b/test/relative.ts @@ -12,4 +12,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ -export type CompilerFunction = (code: string, filename: string) => string; + +export function hello() { + throw new Error("This is an error"); +} diff --git a/packages/cli/src/utils/load-compiler.ts b/test/test.ts similarity index 52% rename from packages/cli/src/utils/load-compiler.ts rename to test/test.ts index a4468aada..cb3a3eab0 100644 --- a/packages/cli/src/utils/load-compiler.ts +++ b/test/test.ts @@ -12,24 +12,10 @@ * * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ -import { CompilerFunction } from "../compiler-function"; +import { hello } from "./relative"; -export function loadCompiler(compilerModule?: string): CompilerFunction { - if (compilerModule != null) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const compilerMod = require(compilerModule); - - if (compilerMod.create == null) { - throw new Error(`No create function defined for ${compilerModule}`); - } - - const compilerObject = compilerMod.create(); - - if (compilerObject.compile == null) { - throw new Error("No compile function defined for create return object"); - } - - return compilerObject.compile; - } - return (code) => code; +export function world() { + console.log(hello()); } + +world(); From 7269236b4a52ff24742b1da78954aa63323a0c58 Mon Sep 17 00:00:00 2001 From: reluc Date: Fri, 24 Oct 2025 13:10:27 +0200 Subject: [PATCH 09/21] feat(cli): unify configuration parameters now envs are treated as config parameters --- .env | 1 + ggg.mjs | 18 ++++ packages/cli/src/cli.ts | 87 ++++++------------- packages/cli/src/configuration.ts | 36 +++++++- .../cli/src/parsers/config-params-parser.ts | 13 +-- packages/cli/src/utils/index.ts | 1 + packages/cli/src/utils/load-env-variables.ts | 16 +++- packages/cli/src/utils/string-to-js-value.ts | 31 +++++++ .../cli/src/wot-servient-schema.conf.json | 11 +-- test.mjs | 39 +++++++++ 10 files changed, 173 insertions(+), 80 deletions(-) create mode 100644 .env create mode 100644 ggg.mjs create mode 100644 packages/cli/src/utils/string-to-js-value.ts create mode 100644 test.mjs diff --git a/.env b/.env new file mode 100644 index 000000000..f593ed82d --- /dev/null +++ b/.env @@ -0,0 +1 @@ +WOT_SERVIENT_HTTP_PORT=8080 diff --git a/ggg.mjs b/ggg.mjs new file mode 100644 index 000000000..2e168a351 --- /dev/null +++ b/ggg.mjs @@ -0,0 +1,18 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +//@ts-expect-error +console.log("gg", WoT); + +export const gg = 123; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f95623e13..d1387f71f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -54,65 +54,25 @@ Run a WoT Servient in the current directory. program.addHelpText( "after", ` -wot-servient.conf.json syntax: +Configuration + +Settings can be applied through three methods, in order of precedence (highest to lowest): + +1. Command-Line Parameters (-p path.to.set=value) +2. Environment Variables (NODE_WOT_PATH_TO_SET=value) (supports .env files too) +3. Configuration File + +For the complete list of available configuration fields and their data types, run: + +wot-servient schema + +In your configuration files you can the following to enable IDE config validation: + { - "servient": { - "clientOnly": CLIENTONLY, - "staticAddress": STATIC, - "scriptAction": RUNSCRIPT - }, - "http": { - "port": HPORT, - "address": HADDRESS, - "baseUri": HBASEURI, - "urlRewrite": HURLREWRITE, - "proxy": PROXY, - "allowSelfSigned": ALLOW - }, - "mqtt" : { - "broker": BROKER-URL, - "username": BROKER-USERNAME, - "password": BROKER-PASSWORD, - "clientId": BROKER-UNIQUEID, - "protocolVersion": MQTT_VERSION - }, - "credentials": { - THING_ID1: { - "token": TOKEN - }, - THING_ID2: { - "username": USERNAME, - "password": PASSWORD - } - } + "$schema": "./node_modules/@node-wot/cli/dist/wot-servient-schema.conf.json" + ... } - -wot-servient.conf.json fields: - CLIENTONLY : boolean setting if no servers shall be started (default=false) - STATIC : string with hostname or IP literal for static address config - RUNSCRIPT : boolean to activate the 'runScript' Action (default=false) - HPORT : integer defining the HTTP listening port - HADDRESS : string defining HTTP address - HBASEURI : string defining HTTP base URI - HURLREWRITE : map (from URL -> to URL) defining HTTP URL rewrites - PROXY : object with "href" field for the proxy URI, - "scheme" field for either "basic" or "bearer", and - corresponding credential fields as defined below - ALLOW : boolean whether self-signed certificates should be allowed - BROKER-URL : URL to an MQTT broker that publisher and subscribers will use - BROKER-UNIQUEID : unique id set by MQTT client while connecting to the broker - MQTT_VERSION : number indicating the MQTT protocol version to be used (3, 4, or 5) - THING_IDx : string with TD "id" for which credentials should be configured - TOKEN : string for providing a Bearer token - USERNAME : string for providing a Basic Auth username - PASSWORD : string for providing a Basic Auth password - --------------------------------------------------------------------------- - -Environment variables must be provided in a .env file in the current working directory. - -Example: -VAR1=Value1 -VAR2=Value2` + ` ); // CLI options declaration @@ -127,8 +87,10 @@ program "choose the desired log level. WARNING: if DEBUG env variable is specified this option gets overridden." ).choices(["debug", "info", "warn", "error"]) ) - .option("-f, --config-file ", "load configuration from specified file", (value, previous) => - parseConfigFile(value, previous) + .option( + "-f, --config-file ", + "load configuration from specified file (default: $(pwd)/wot-servient.conf.json", + (value, previous) => parseConfigFile(value, previous) ) .option( "-p, --config-params ", @@ -144,6 +106,13 @@ program.addArgument( ) ); +program + .command("schema") + .description("prints the json schema for the configuration file") + .action(() => { + console.log(JSON.stringify(ConfigSchema, null, 2)); + }); + program.action(async function (_, options, cmd) { // Allow user to personalized the env if (process.env.DEBUG == null) { diff --git a/packages/cli/src/configuration.ts b/packages/cli/src/configuration.ts index a95f5b5c2..a39e10783 100644 --- a/packages/cli/src/configuration.ts +++ b/packages/cli/src/configuration.ts @@ -19,6 +19,10 @@ import { DotenvParseOutput } from "dotenv"; import _ from "lodash"; import { readFile } from "fs/promises"; import { ValidateFunction, ValidationError } from "ajv"; +import { stringToJSValue } from "./utils"; +import { createLoggers } from "@node-wot/core"; + +const { debug } = createLoggers("cli", "cli-default-servient"); type Merge = { [K in keyof T as K extends keyof U ? never : K]: T[K] } & { [L in keyof U & keyof T]: Merge; @@ -41,7 +45,6 @@ export type Configuration = FromSchema; export const defaultConfiguration = Object.freeze({ servient: { clientOnly: false, - scriptAction: false, }, http: { port: 8080, @@ -56,6 +59,32 @@ export const defaultConfiguration = Object.freeze({ export type ConfigurationAfterDefaults = Merge>>; +/** + * Helper function to convert an ENV key to a camelCased path + * using the schema as reference (e.g., SERVIENT_STATICADDRESS -> servient.staticAddress) + */ +function envKeyToConfigPath(envKey: string): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let properties: { [x: string]: any } = schema.properties; + const pathParts = envKey.toLowerCase().replace(/__/g, ".").replace(/_/g, ".").split("."); + let path = ""; + for (const part of pathParts) { + const matchedProperty = Object.keys(properties).find((prop) => prop.toLowerCase() === part); + if (matchedProperty != null) { + path += (path.length > 0 ? "." : "") + matchedProperty; + const nextProperty = properties[matchedProperty as keyof typeof properties]; + if ("properties" in nextProperty) { + properties = nextProperty.properties; + } + } else { + // If no matching property is found, append the original part we are going to catch + // errors in the validation phase later + path += (path.length > 0 ? "." : "") + part; + } + } + return path; +} + export async function buildConfig( options: Record, configuration: Configuration, @@ -65,7 +94,10 @@ export async function buildConfig( let config = configuration; for (const [key, value] of Object.entries(dotEnvConfigParameters)) { - const obj = _.set({}, key, value); + debug("Applying env variable %s=%s", key, value); + const path = envKeyToConfigPath(key); + debug("Mapped to config path %s", path); + const obj = _.set({}, path, stringToJSValue(value)); config = _.merge(config, obj); } diff --git a/packages/cli/src/parsers/config-params-parser.ts b/packages/cli/src/parsers/config-params-parser.ts index 0448b9e10..3f1c60cb9 100644 --- a/packages/cli/src/parsers/config-params-parser.ts +++ b/packages/cli/src/parsers/config-params-parser.ts @@ -15,6 +15,7 @@ import { ErrorObject, ValidateFunction } from "ajv"; import { InvalidArgumentError } from "commander"; import _ from "lodash"; +import { stringToJSValue } from "../utils"; export function parseConfigParams(param: string, previous: unknown, validator: ValidateFunction) { // Validate key-value pair @@ -22,18 +23,10 @@ export function parseConfigParams(param: string, previous: unknown, validator: V throw new InvalidArgumentError("Invalid key-value pair"); } const fieldNamePath = param.split(":=")[0]; - const fieldNameValue = param.split(":=")[1]; - let fieldNameValueCast; - if (Number(fieldNameValue)) { - fieldNameValueCast = +fieldNameValue; - } else if (fieldNameValue === "true" || fieldNameValue === "false") { - fieldNameValueCast = Boolean(fieldNameValue); - } else { - fieldNameValueCast = fieldNamePath; - } + const fieldNameValue = stringToJSValue(param.split(":=")[1]); // Build object using dot-notation JSON path - const obj = _.set({}, fieldNamePath, fieldNameValueCast); + const obj = _.set({}, fieldNamePath, fieldNameValue); if (!validator(obj)) { throw new InvalidArgumentError( `Config parameter '${param}' is not valid: ${(validator.errors ?? []) diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index 2dcb67164..4c8d7868b 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -14,3 +14,4 @@ ********************************************************************************/ export * from "./load-env-variables"; export * from "./set-log-level"; +export * from "./string-to-js-value"; diff --git a/packages/cli/src/utils/load-env-variables.ts b/packages/cli/src/utils/load-env-variables.ts index dcbbafc1a..16c5c7910 100644 --- a/packages/cli/src/utils/load-env-variables.ts +++ b/packages/cli/src/utils/load-env-variables.ts @@ -15,12 +15,20 @@ import * as dotenv from "dotenv"; import ErrnoException = NodeJS.ErrnoException; -export function loadEnvVariables() { +export function loadEnvVariables(prefix: string = "WOT_SERVIENT_"): { [key: string]: string } { const env: dotenv.DotenvConfigOutput = dotenv.config(); - const errorNoException: ErrnoException | undefined = env.error; + const errornoException: ErrnoException | undefined = env.error; // ignore file not found but throw otherwise - if (errorNoException?.code !== "ENOENT") { + if (errornoException != null && errornoException.code !== "ENOENT") { throw env.error; } - return env.parsed ?? {}; + + // Filter out not node-wot related variables + return Object.keys(process.env) + .filter((key) => key.startsWith(prefix)) + .reduce((obj: { [key: string]: string }, key: string) => { + const shortKey = key.substring(prefix.length); + obj[shortKey] = process.env[key] as string; + return obj; + }, {}); } diff --git a/packages/cli/src/utils/string-to-js-value.ts b/packages/cli/src/utils/string-to-js-value.ts new file mode 100644 index 000000000..90e43754e --- /dev/null +++ b/packages/cli/src/utils/string-to-js-value.ts @@ -0,0 +1,31 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +/** + * Converts a string to a Number, Boolean, or return the original string. + * Useful for parsing envs or CLI arguments. + * + * @param value - The string value to convert. + * @returns The converted value as Number, Boolean, or String. + */ +export function stringToJSValue(value: string) { + if (Number(value)) { + return +value; + } else if (value === "true" || value === "false") { + return Boolean(value); + } else { + return value; + } +} diff --git a/packages/cli/src/wot-servient-schema.conf.json b/packages/cli/src/wot-servient-schema.conf.json index 30376ebfa..378c82a03 100644 --- a/packages/cli/src/wot-servient-schema.conf.json +++ b/packages/cli/src/wot-servient-schema.conf.json @@ -8,15 +8,13 @@ "type": "object", "properties": { "clientOnly": { + "description": "setting if no servers shall be started", "type": "boolean", "default": false }, "staticAddress": { - "type": "string" - }, - "scriptAction": { - "type": "boolean", - "default": false + "type": "string", + "description": "hostname or IP literal for static address config" } }, "additionalProperties": false @@ -35,10 +33,12 @@ }, "urlRewrite": { "type": "object", + "description": "map (from URL -> to URL) defining HTTP URL rewrites", "additionalProperties": { "type": "string" } }, "proxy": { "type": "object", + "description": "object with 'href' field for the proxy URI, scheme field for either 'basic' or 'bearer', and corresponding credential fields as defined below", "required": ["href"], "properties": { "href": { @@ -60,6 +60,7 @@ "additionalProperties": false }, "allowSelfSigned": { + "description": "whether self-signed certificates should be allowed", "type": "boolean" }, "serverKey": { diff --git a/test.mjs b/test.mjs new file mode 100644 index 000000000..69b92183f --- /dev/null +++ b/test.mjs @@ -0,0 +1,39 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { gg } from "./ggg.mjs"; +// eslint-disable-next-line import/no-extraneous-dependencies +import Ajv from "ajv"; +const ajv = new Ajv(); +const schema = { + type: "object", + properties: { + foo: { type: "string" }, + bar: { type: "number" }, + }, + required: ["foo", "bar"], + additionalProperties: false, +}; + +const validate = ajv.compile(schema); + +const validData = { foo: "hello", bar: 42 }; +const invalidData = { foo: "hello", bar: "not a number" }; + +console.log("validData is valid:", validate(validData)); +console.log("invalidData is valid:", validate(invalidData)); +if (!validate(invalidData)) { + console.log("Validation errors:", validate.errors); +} +console.log("gg", gg); From 1f1d88e0b1daa344e86ace55c432069309dd717a Mon Sep 17 00:00:00 2001 From: reluc Date: Fri, 28 Nov 2025 12:41:05 +0100 Subject: [PATCH 10/21] chore: remove testing files --- ggg.mjs | 18 ------------------ test.mjs | 39 --------------------------------------- test/relative.ts | 18 ------------------ test/test.ts | 21 --------------------- 4 files changed, 96 deletions(-) delete mode 100644 ggg.mjs delete mode 100644 test.mjs delete mode 100644 test/relative.ts delete mode 100644 test/test.ts diff --git a/ggg.mjs b/ggg.mjs deleted file mode 100644 index 2e168a351..000000000 --- a/ggg.mjs +++ /dev/null @@ -1,18 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2025 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and - * Document License (2015-05-13) which is available at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. - * - * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ -//@ts-expect-error -console.log("gg", WoT); - -export const gg = 123; diff --git a/test.mjs b/test.mjs deleted file mode 100644 index 69b92183f..000000000 --- a/test.mjs +++ /dev/null @@ -1,39 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2025 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and - * Document License (2015-05-13) which is available at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. - * - * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ -import { gg } from "./ggg.mjs"; -// eslint-disable-next-line import/no-extraneous-dependencies -import Ajv from "ajv"; -const ajv = new Ajv(); -const schema = { - type: "object", - properties: { - foo: { type: "string" }, - bar: { type: "number" }, - }, - required: ["foo", "bar"], - additionalProperties: false, -}; - -const validate = ajv.compile(schema); - -const validData = { foo: "hello", bar: 42 }; -const invalidData = { foo: "hello", bar: "not a number" }; - -console.log("validData is valid:", validate(validData)); -console.log("invalidData is valid:", validate(invalidData)); -if (!validate(invalidData)) { - console.log("Validation errors:", validate.errors); -} -console.log("gg", gg); diff --git a/test/relative.ts b/test/relative.ts deleted file mode 100644 index e1b0a72c0..000000000 --- a/test/relative.ts +++ /dev/null @@ -1,18 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2025 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and - * Document License (2015-05-13) which is available at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. - * - * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ - -export function hello() { - throw new Error("This is an error"); -} diff --git a/test/test.ts b/test/test.ts deleted file mode 100644 index cb3a3eab0..000000000 --- a/test/test.ts +++ /dev/null @@ -1,21 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2025 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and - * Document License (2015-05-13) which is available at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. - * - * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ -import { hello } from "./relative"; - -export function world() { - console.log(hello()); -} - -world(); From 28c8a6fa311d6c99582802d428234ff4a9daec42 Mon Sep 17 00:00:00 2001 From: reluc Date: Fri, 28 Nov 2025 15:44:23 +0100 Subject: [PATCH 11/21] test(cli): cover new functionalities with basic tests --- packages/cli/.gitignore | 1 + packages/cli/package.json | 2 +- packages/cli/src/utils/string-to-js-value.ts | 4 +- packages/cli/test/configuration.ts | 151 +++++++++++++++++ packages/cli/test/executor.ts | 110 +++++++++++++ .../cli/test/parsers/config-file-parser.ts | 87 ++++++++++ .../cli/test/parsers/config-params-parser.ts | 95 +++++++++++ packages/cli/test/parsers/ip-parser.ts | 62 +++++++ packages/cli/test/resources/.gitkeep | 0 packages/cli/test/runtime-test.ts | 154 ------------------ packages/cli/test/utils/load-env-variables.ts | 76 +++++++++ packages/cli/test/utils/set-log-level.ts | 60 +++++++ packages/cli/test/utils/string-to-js-value.ts | 58 +++++++ 13 files changed, 703 insertions(+), 157 deletions(-) create mode 100644 packages/cli/test/configuration.ts create mode 100644 packages/cli/test/executor.ts create mode 100644 packages/cli/test/parsers/config-file-parser.ts create mode 100644 packages/cli/test/parsers/config-params-parser.ts create mode 100644 packages/cli/test/parsers/ip-parser.ts create mode 100644 packages/cli/test/resources/.gitkeep delete mode 100644 packages/cli/test/runtime-test.ts create mode 100644 packages/cli/test/utils/load-env-variables.ts create mode 100644 packages/cli/test/utils/set-log-level.ts create mode 100644 packages/cli/test/utils/string-to-js-value.ts diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index c83f90a2b..9f7645a6d 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -1 +1,2 @@ src/generated +test/resources diff --git a/packages/cli/package.json b/packages/cli/package.json index 6141302b5..08582f77c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,7 +40,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.ts\" \"**/*.json\"", - "test": "mocha --require ts-node/register --extension ts" + "test": "mocha --recursive --require ts-node/register --extension ts" }, "bugs": { "url": "https://github.com/eclipse-thingweb/node-wot/issues" diff --git a/packages/cli/src/utils/string-to-js-value.ts b/packages/cli/src/utils/string-to-js-value.ts index 90e43754e..f4ca32a0d 100644 --- a/packages/cli/src/utils/string-to-js-value.ts +++ b/packages/cli/src/utils/string-to-js-value.ts @@ -21,10 +21,10 @@ * @returns The converted value as Number, Boolean, or String. */ export function stringToJSValue(value: string) { - if (Number(value)) { + if (!isNaN(Number(value))) { return +value; } else if (value === "true" || value === "false") { - return Boolean(value); + return value === "true"; } else { return value; } diff --git a/packages/cli/test/configuration.ts b/packages/cli/test/configuration.ts new file mode 100644 index 000000000..fd0b09cb3 --- /dev/null +++ b/packages/cli/test/configuration.ts @@ -0,0 +1,151 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { suite, test } from "@testdeck/mocha"; +import { should, expect, use as chaiUse } from "chai"; +import { buildConfig, buildConfigFromFile, defaultConfiguration, Configuration } from "../src/configuration"; +import Ajv, { ValidateFunction } from "ajv"; +import ConfigSchema from "../src/generated/wot-servient-schema.conf"; +import chaiAsPromised from "chai-as-promised"; +import { writeFileSync, unlinkSync } from "fs"; +import { join } from "path"; +import { ValidationError } from "ajv"; + +should(); +chaiUse(chaiAsPromised); + +@suite("Configuration management") +class ConfigurationTest { + private static validator: ValidateFunction; + private testFilePath!: string; + + static before() { + const ajv = new Ajv({ strict: true, allErrors: true }); + ConfigurationTest.validator = ajv.compile(ConfigSchema) as ValidateFunction; + } + + before() { + this.testFilePath = join(__dirname, "./resources", "test-config-" + Date.now() + ".json"); + } + + after() { + try { + unlinkSync(this.testFilePath); + } catch { + // File may not exist + } + } + + @test async "should use default configuration when none provided"() { + const result = await buildConfig({}, defaultConfiguration, {}, ConfigurationTest.validator); + + expect(result).to.have.property("http"); + expect(result.http.port).to.equal(8080); + expect(result.coap.port).to.equal(5683); + expect(result.logLevel).to.equal("warn"); + } + + @test async "should handle credentials in config"() { + const config = { ...defaultConfiguration, credentials: { THING_ID_1: { username: "user", password: "pass" } } }; + const result = await buildConfig({}, config, {}, ConfigurationTest.validator); + + expect(result.credentials).to.have.property("THING_ID_1"); + } + + @test async "should merge environment variables with defaults"() { + const env = { HTTP_PORT: "9000" }; + const result = await buildConfig({}, defaultConfiguration, env, ConfigurationTest.validator); + + expect(result.http.port).to.equal(9000); + } + + @test async "should apply config parameters"() { + const options = { configParams: { http: { port: 8888 } } }; + const result = await buildConfig(options, defaultConfiguration, {}, ConfigurationTest.validator); + + expect(result.http.port).to.equal(8888); + } + + @test async "should merge environment variables and config parameters"() { + const env = { HTTP_PORT: "9000" }; + const options = { configParams: { coap: { port: 6000 } } }; + const result = await buildConfig(options, defaultConfiguration, env, ConfigurationTest.validator); + + expect(result.http.port).to.equal(9000); + expect(result.coap.port).to.equal(6000); + } + + @test "should validate merged configuration"() { + const options = { configParams: { http: { port: "invalid" } } }; + + expect(buildConfig(options, defaultConfiguration, {}, ConfigurationTest.validator)).to.eventually.throw( + ValidationError + ); + } + + @test async "should apply default values to provided config"() { + const customConfig = { http: { port: 8888 } }; + const result = await buildConfig({}, customConfig, {}, ConfigurationTest.validator); + + expect(result.http.port).to.equal(8888); + expect(result.coap.port).to.equal(defaultConfiguration.coap.port); + expect(result.logLevel).to.equal(defaultConfiguration.logLevel); + } + + @test async "should read and build config from file"() { + const config = { http: { port: 7777 } }; + writeFileSync(this.testFilePath, JSON.stringify(config)); + + const result = await buildConfigFromFile({}, this.testFilePath, {}, ConfigurationTest.validator); + + expect(result.http.port).to.equal(7777); + } + + @test async "should merge file config with environment variables"() { + const config = { http: { port: 7777 } }; + writeFileSync(this.testFilePath, JSON.stringify(config)); + + const env = { COAP_PORT: "6000" }; + const result = await buildConfigFromFile({}, this.testFilePath, env, ConfigurationTest.validator); + + expect(result.http.port).to.equal(7777); + expect(result.coap.port).to.equal(6000); + } + + @test async "should handle configFile option"() { + const config = { http: { port: 5555 } }; + writeFileSync(this.testFilePath, JSON.stringify(config)); + + const options = { configFile: this.testFilePath }; + const result = await buildConfigFromFile(options, this.testFilePath, {}, ConfigurationTest.validator); + + expect(result.http.port).to.equal(5555); + } + + @test "should throw error for invalid config file"() { + writeFileSync(this.testFilePath, "{ invalid json }"); + + expect(buildConfigFromFile({}, this.testFilePath, {}, ConfigurationTest.validator)).to.eventually.throw(); + } + + @test async "should convert string env variables to appropriate types"() { + const env = { HTTP_PORT: "8080", SERVIENT_CLIENTONLY: "true", COAP_PORT: "5683" }; + const result = await buildConfig({}, defaultConfiguration, env, ConfigurationTest.validator); + + expect(result.http.port).to.equal(8080); + expect(result.servient.clientOnly).to.equal(true); + expect(result.coap.port).to.equal(5683); + } +} diff --git a/packages/cli/test/executor.ts b/packages/cli/test/executor.ts new file mode 100644 index 000000000..e1b39d6ac --- /dev/null +++ b/packages/cli/test/executor.ts @@ -0,0 +1,110 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { suite, test } from "@testdeck/mocha"; +import { should, expect } from "chai"; +import { Executor, WoTContext } from "../src/executor"; +import { writeFileSync, unlinkSync } from "fs"; +import { join } from "path"; +import { Helpers } from "@node-wot/core"; + +should(); + +@suite("Executor") +class ExecutorTest { + private executor!: Executor; + private testFilePath!: string; + private mockWoTContext!: WoTContext; + + before() { + this.executor = new Executor(); + this.testFilePath = join(__dirname, "./resources", "test-script-" + Date.now()); + this.mockWoTContext = { + // We are not using WoT inside this testing scripts + runtime: {} as typeof WoT, + helpers: {} as Helpers, + }; + } + + after() { + try { + unlinkSync(this.testFilePath + ".js"); + } catch { + // File may not exist + } + try { + unlinkSync(this.testFilePath + ".mjs"); + } catch { + // File may not exist + } + try { + unlinkSync(this.testFilePath + ".ts"); + } catch { + // File may not exist + } + try { + unlinkSync(this.testFilePath + ".tsx"); + } catch { + // File may not exist + } + } + + @test async "should execute JavaScript file"() { + const scriptContent = "module.exports = 'test result';"; + writeFileSync(this.testFilePath + ".js", scriptContent); + + const result = await this.executor.exec(this.testFilePath + ".js", this.mockWoTContext); + + expect(result).to.equal("test result"); + } + + @test async "should have WoT defined"() { + const scriptContent = "module.exports = typeof global.WoT !== 'undefined';"; + writeFileSync(this.testFilePath + ".js", scriptContent); + + const result = await this.executor.exec(this.testFilePath + ".js", this.mockWoTContext); + + expect(result).to.be.true; + } + + @test async "should handle module exports"() { + const scriptContent = "module.exports = { message: 'hello' };"; + writeFileSync(this.testFilePath + ".js", scriptContent); + + const result = await this.executor.exec(this.testFilePath + ".js", this.mockWoTContext); + + expect(result).to.have.property("message", "hello"); + } + + @test async "should detect TypeScript files by .ts extension"() { + const scriptContent = "export const value: number = 42;"; + writeFileSync(this.testFilePath + ".ts", scriptContent); + + const { value } = (await this.executor.exec(this.testFilePath + ".ts", this.mockWoTContext)) as { + value: number; + }; + + expect(value).to.be.eq(42); + } + + @test async "should handle .mjs files as ES modules"() { + const filePath = this.testFilePath + ".mjs"; + const scriptContent = "export const value = 'es module';"; + writeFileSync(filePath, scriptContent); + + const { value } = (await this.executor.exec(filePath, this.mockWoTContext)) as { value: string }; + expect(value).to.be.eq("es module"); + } +} diff --git a/packages/cli/test/parsers/config-file-parser.ts b/packages/cli/test/parsers/config-file-parser.ts new file mode 100644 index 000000000..4c11ecf89 --- /dev/null +++ b/packages/cli/test/parsers/config-file-parser.ts @@ -0,0 +1,87 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { suite, test } from "@testdeck/mocha"; +import { should, expect } from "chai"; +import { parseConfigFile } from "../../src/parsers/config-file-parser"; +import { InvalidArgumentError } from "commander"; +import { writeFileSync, unlinkSync } from "fs"; +import { join } from "path"; + +should(); + +@suite("parseConfigFile parser") +class ConfigFileParserTest { + private testFilePath!: string; + + before() { + this.testFilePath = join(__dirname, "../resources", "test-config-" + Date.now() + ".json"); + } + + after() { + try { + unlinkSync(this.testFilePath); + } catch { + // File may not exist if test failed before creation + } + } + + @test "should parse valid JSON config file"() { + const validConfig = { http: { port: 8080 }, coap: { port: 5683 } }; + writeFileSync(this.testFilePath, JSON.stringify(validConfig), { flag: "w+" }); + + const result = parseConfigFile(this.testFilePath, undefined); + + expect(result).to.equal(this.testFilePath); + } + + @test "should throw error for invalid JSON"() { + writeFileSync(this.testFilePath, "{ invalid json }"); + + expect(() => parseConfigFile(this.testFilePath, undefined)).to.throw(InvalidArgumentError); + } + + @test "should throw error for non-existent file"() { + expect(() => parseConfigFile("/nonexistent/file.json", undefined)).to.throw(InvalidArgumentError); + } + + @test "should throw error for empty JSON object"() { + writeFileSync(this.testFilePath, "{}"); + + const result = parseConfigFile(this.testFilePath, undefined); + expect(result).to.equal(this.testFilePath); + } + + @test "should handle complex JSON structures"() { + const complexConfig = { + servient: { clientOnly: false }, + http: { port: 8080, allowSelfSigned: false }, + coap: { port: 5683 }, + credentials: { user: "admin" }, + }; + writeFileSync(this.testFilePath, JSON.stringify(complexConfig)); + + const result = parseConfigFile(this.testFilePath, undefined); + + expect(result).to.equal(this.testFilePath); + } + + @test "should handle JSON array at root"() { + writeFileSync(this.testFilePath, JSON.stringify([1, 2, 3])); + + const result = parseConfigFile(this.testFilePath, undefined); + expect(result).to.equal(this.testFilePath); + } +} diff --git a/packages/cli/test/parsers/config-params-parser.ts b/packages/cli/test/parsers/config-params-parser.ts new file mode 100644 index 000000000..86d860399 --- /dev/null +++ b/packages/cli/test/parsers/config-params-parser.ts @@ -0,0 +1,95 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { suite, test } from "@testdeck/mocha"; +import { should, expect } from "chai"; +import { parseConfigParams } from "../../src/parsers/config-params-parser"; +import { InvalidArgumentError } from "commander"; +import Ajv, { ValidateFunction } from "ajv"; +import ConfigSchema from "../../src/generated/wot-servient-schema.conf"; +import { Configuration } from "../../src/configuration"; + +should(); + +@suite("parseConfigParams parser") +class ConfigParamsParserTest { + private static validator: ValidateFunction; + + static before() { + const ajv = new Ajv({ strict: true, allErrors: true }); + ConfigParamsParserTest.validator = ajv.compile(ConfigSchema) as ValidateFunction; + } + + @test "should parse valid config parameter"() { + const result = parseConfigParams("http.port:=8080", undefined, ConfigParamsParserTest.validator); + + expect(result).to.have.property("http"); + expect((result as any).http).to.have.property("port", 8080); + } + + @test "should parse nested config parameter"() { + const result = parseConfigParams("servient.clientOnly:=true", undefined, ConfigParamsParserTest.validator); + + expect(result).to.have.property("servient"); + expect((result as any).servient).to.have.property("clientOnly", true); + } + + @test "should throw error for invalid key-value format"() { + expect(() => parseConfigParams("invalid_format", undefined, ConfigParamsParserTest.validator)).to.throw( + InvalidArgumentError + ); + } + + @test "should throw error for missing colon-equals separator"() { + expect(() => parseConfigParams("http.port=8080", undefined, ConfigParamsParserTest.validator)).to.throw( + InvalidArgumentError + ); + } + + @test "should throw error for invalid config parameter"() { + expect(() => + parseConfigParams("nonexistent.path:=value", undefined, ConfigParamsParserTest.validator) + ).to.throw(InvalidArgumentError); + } + + @test "should merge with previous parameters"() { + let result = parseConfigParams("http.port:=8080", undefined, ConfigParamsParserTest.validator); + result = parseConfigParams("coap.port:=5683", result, ConfigParamsParserTest.validator); + + expect(result).to.have.property("http"); + expect(result).to.have.property("coap"); + expect((result as any).http.port).to.equal(8080); + expect((result as any).coap.port).to.equal(5683); + } + + @test "should handle boolean values"() { + const result = parseConfigParams("servient.clientOnly:=true", undefined, ConfigParamsParserTest.validator); + + expect((result as any).servient.clientOnly).to.equal(true); + } + + @test "should handle numeric values"() { + const result = parseConfigParams("http.port:=9000", undefined, ConfigParamsParserTest.validator); + + expect((result as any).http.port).to.equal(9000); + } + + @test "should override previous parameter"() { + let result = parseConfigParams("http.port:=8080", undefined, ConfigParamsParserTest.validator); + result = parseConfigParams("http.port:=9000", result, ConfigParamsParserTest.validator); + + expect((result as any).http.port).to.equal(9000); + } +} diff --git a/packages/cli/test/parsers/ip-parser.ts b/packages/cli/test/parsers/ip-parser.ts new file mode 100644 index 000000000..e55d8b265 --- /dev/null +++ b/packages/cli/test/parsers/ip-parser.ts @@ -0,0 +1,62 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { suite, test } from "@testdeck/mocha"; +import { should, expect } from "chai"; +import { parseIp } from "../../src/parsers/ip-parser"; +import { InvalidArgumentError } from "commander"; + +should(); + +@suite("parseIp parser") +class IpParserTest { + @test "should parse valid IP address with port"() { + const result = parseIp("127.0.0.1:9229", ""); + expect(result).to.equal("127.0.0.1:9229"); + } + + @test "should parse valid hostname with port"() { + const result = parseIp("localhost:8080", ""); + expect(result).to.equal("localhost:8080"); + } + + @test "should parse valid port only"() { + const result = parseIp(":9229", ""); + expect(result).to.equal(":9229"); + } + + @test "should parse IP address without port"() { + const result = parseIp("192.168.1.1", ""); + expect(result).to.equal("192.168.1.1"); + } + + @test "should throw error for invalid format"() { + expect(() => parseIp("invalid@address:9229", "")).to.throw(InvalidArgumentError); + } + + @test "should throw error for port too short"() { + expect(() => parseIp("127.0.0.1:1", "")).to.throw(InvalidArgumentError); + } + + @test "should accept single-char hostname"() { + const result = parseIp("a:9229", ""); + expect(result).to.equal("a:9229"); + } + + @test "should accept full IP address ranges"() { + const result = parseIp("192.168.0.255:65535", ""); + expect(result).to.equal("192.168.0.255:65535"); + } +} diff --git a/packages/cli/test/resources/.gitkeep b/packages/cli/test/resources/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/cli/test/runtime-test.ts b/packages/cli/test/runtime-test.ts deleted file mode 100644 index 4de37d83e..000000000 --- a/packages/cli/test/runtime-test.ts +++ /dev/null @@ -1,154 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2022 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and - * Document License (2015-05-13) which is available at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. - * - * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ - -/** - * Basic test suite to demonstrate test setup - * uncomment the @skip to see failing tests - * - * h0ru5: there is currently some problem with VSC failing to recognize experimentalDecorators option, it is present in both tsconfigs - */ - -import { suite, test } from "@testdeck/mocha"; -import { should, assert } from "chai"; -import DefaultServient from "../src/cli-default-servient"; - -import fs from "fs"; -import { EventEmitter } from "stream"; -// should must be called to augment all variables -should(); - -@suite("Test suite for script runtime") -class WoTRuntimeTest { - static servient: DefaultServient; - - static WoT: typeof WoT; - - exit: (code?: number) => never = () => { - throw new Error(""); - }; - - static async before() { - EventEmitter.setMaxListeners(20); - this.servient = new DefaultServient(true); - await this.servient.start(); - } - - beforeEach() { - this.exit = process.exit; - } - - afterEach() { - process.exit = this.exit; - } - - static async after(): Promise { - await this.servient.shutdown(); - } - - @test "should provide cli args"() { - const envScript = `process.argv[0]`; - - const test = WoTRuntimeTest.servient.runScript(envScript, undefined, { argv: ["myArg"] }); - assert.equal(test, "myArg"); - } - - @test "should use the compiler function"() { - const envScript = `this is not js`; - - const test = WoTRuntimeTest.servient.runScript(envScript, undefined, { - compiler: () => { - return "'ok'"; - }, - }); - assert.equal(test, "ok"); - } - - @test "should provide env variables"() { - const envScript = `process.env.MY_VAR`; - const test = WoTRuntimeTest.servient.runScript(envScript, undefined, { env: { MY_VAR: "test" } }); - assert.equal(test, "test"); - } - - @test "should hide system env variables"() { - const envScript = `module.exports = process.env.OS`; - - const test = WoTRuntimeTest.servient.runScript(envScript); - assert.equal(test, undefined); - } - - @test "should require node builtin module"() { - const envScript = `require("fs")`; - - const test = WoTRuntimeTest.servient.runScript(envScript); - assert.equal(test, fs); - } - - @test "should catch synchronous errors"() { - const failNowScript = `throw new Error("Synchronous error in Servient sandbox");`; - - assert.doesNotThrow(() => { - WoTRuntimeTest.servient.runScript(failNowScript); - }); - } - - @test "should catch bad errors"() { - const failNowScript = `throw "Bad synchronous error in Servient sandbox";`; - - assert.doesNotThrow(() => { - WoTRuntimeTest.servient.runScript(failNowScript); - }); - } - - @test "should catch bad asynchronous errors"(done: Mocha.Done) { - // Mocha does not like string errors: https://github.com/trufflesuite/ganache-cli/issues/658 - // so here I am removing its listeners for uncaughtException. - // WARNING: Remove this line as soon the issue is resolved. - const listeners = this.clearUncaughtListeners(); - let called = false; - - this.mockupProcessExitWithFunction(() => { - if (!called) { - done(); - this.restoreUncaughtListeners(listeners); - called = true; - } - }); - - const failThenScript = `setTimeout( () => { throw "Bad asynchronous error in Servient sandbox"; }, 1);`; - - assert.doesNotThrow(() => { - WoTRuntimeTest.servient.runScript(failThenScript); - }); - } - - private mockupProcessExitWithFunction(func: () => void) { - // Mockup is needed cause servient will call process.exit() - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - process.exit = func; - } - - private clearUncaughtListeners() { - const listeners = process.listeners("uncaughtException"); - process.removeAllListeners("uncaughtException"); - return listeners; - } - - private restoreUncaughtListeners(listeners: Array) { - listeners.forEach((element) => { - process.on("uncaughtException", element); - }); - } -} diff --git a/packages/cli/test/utils/load-env-variables.ts b/packages/cli/test/utils/load-env-variables.ts new file mode 100644 index 000000000..b5f37fe7a --- /dev/null +++ b/packages/cli/test/utils/load-env-variables.ts @@ -0,0 +1,76 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { suite, test } from "@testdeck/mocha"; +import { should, expect } from "chai"; +import { loadEnvVariables } from "../../src/utils/load-env-variables"; + +should(); + +@suite("loadEnvVariables utility") +class LoadEnvVariablesTest { + private originalEnv!: NodeJS.ProcessEnv; + + beforeEach() { + this.originalEnv = { ...process.env }; + } + + afterEach() { + process.env = this.originalEnv; + } + + @test "should filter environment variables by prefix"() { + process.env.WOT_SERVIENT_HTTP_PORT = "8080"; + process.env.WOT_SERVIENT_COAP_PORT = "5683"; + process.env.OTHER_VAR = "value"; + + const result = loadEnvVariables(); + + expect(result).to.have.property("HTTP_PORT", "8080"); + expect(result).to.have.property("COAP_PORT", "5683"); + expect(result).to.not.have.property("OTHER_VAR"); + } + + @test "should return empty object when no matching variables are found"() { + delete process.env.WOT_SERVIENT_HTTP_PORT; + delete process.env.WOT_SERVIENT_COAP_PORT; + + const result = loadEnvVariables(); + + expect(result).to.be.an("object"); + expect(Object.keys(result).length).to.equal(0); + } + + @test "should use custom prefix"() { + process.env.CUSTOM_PREFIX_VAR1 = "value1"; + process.env.CUSTOM_PREFIX_VAR2 = "value2"; + process.env.WOT_SERVIENT_VAR3 = "value3"; + + const result = loadEnvVariables("CUSTOM_PREFIX_"); + + expect(result).to.have.property("VAR1", "value1"); + expect(result).to.have.property("VAR2", "value2"); + expect(result).to.not.have.property("VAR3"); + } + + @test "should remove prefix from keys"() { + process.env.WOT_SERVIENT_MYKEY = "myvalue"; + + const result = loadEnvVariables(); + + expect(result).to.have.property("MYKEY", "myvalue"); + expect(Object.keys(result)).to.not.include("WOT_SERVIENT_MYKEY"); + } +} diff --git a/packages/cli/test/utils/set-log-level.ts b/packages/cli/test/utils/set-log-level.ts new file mode 100644 index 000000000..7ccdc7d19 --- /dev/null +++ b/packages/cli/test/utils/set-log-level.ts @@ -0,0 +1,60 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { suite, test } from "@testdeck/mocha"; +import { should, expect } from "chai"; +import { setLogLevel } from "../../src/utils/set-log-level"; +import * as logger from "debug"; + +should(); + +@suite("setLogLevel utility") +class SetLogLevelTest { + @test "should set debug log level"() { + setLogLevel("debug"); + // Verify by checking that debug enables all node-wot loggers + expect(logger.enabled("node-wot:test")).to.be.true; + } + + @test "should set info log level"() { + setLogLevel("info"); + // info level should enable error, warn, and info logs + expect(logger.enabled("node-wot:test:error")).to.be.true; + expect(logger.enabled("node-wot:test:warn")).to.be.true; + expect(logger.enabled("node-wot:test:info")).to.be.true; + } + + @test "should set warn log level"() { + setLogLevel("warn"); + // warn level should enable error and warn logs + expect(logger.enabled("node-wot:test:error")).to.be.true; + expect(logger.enabled("node-wot:test:warn")).to.be.true; + } + + @test "should set error log level"() { + setLogLevel("error"); + // error level should only enable error logs + expect(logger.enabled("node-wot:test:error")).to.be.true; + } + + @test "should disable all loggers before reconfiguring"() { + setLogLevel("debug"); + expect(logger.enabled("node-wot:test")).to.be.true; + + setLogLevel("error"); + // After switching to error level, debug logs should be disabled + expect(logger.enabled("node-wot:test:debug")).to.be.false; + } +} diff --git a/packages/cli/test/utils/string-to-js-value.ts b/packages/cli/test/utils/string-to-js-value.ts new file mode 100644 index 000000000..c968066f7 --- /dev/null +++ b/packages/cli/test/utils/string-to-js-value.ts @@ -0,0 +1,58 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { suite, test } from "@testdeck/mocha"; +import { should, expect } from "chai"; +import { stringToJSValue } from "../../src/utils/string-to-js-value"; + +should(); + +@suite("stringToJSValue utility") +class StringToJSValueTest { + @test "should convert numeric string to number"() { + expect(stringToJSValue("42")).to.equal(42); + expect(stringToJSValue("0")).to.equal(0); + expect(stringToJSValue("1000")).to.equal(1000); + } + + @test "should convert string 'true' to boolean true"() { + expect(stringToJSValue("true")).to.equal(true); + } + + @test "should convert string 'false' to boolean false"() { + expect(stringToJSValue("false")).to.equal(false); + } + + @test "should return original string for non-numeric, non-boolean values"() { + expect(stringToJSValue("hello")).to.equal("hello"); + expect(stringToJSValue("myValue")).to.equal("myValue"); + } + + @test "should handle floating point numbers"() { + expect(stringToJSValue("3.14")).to.equal(3.14); + expect(stringToJSValue("0.5")).to.equal(0.5); + } + + @test "should handle negative numbers"() { + expect(stringToJSValue("-42")).to.equal(-42); + expect(stringToJSValue("-3.14")).to.equal(-3.14); + } + + @test "should return string for non-strictly-boolean values"() { + expect(stringToJSValue("True")).to.equal("True"); + expect(stringToJSValue("False")).to.equal("False"); + expect(stringToJSValue("TRUE")).to.equal("TRUE"); + } +} From 93cd4287b7af78a905f1ff2ea2d37a04634eba6f Mon Sep 17 00:00:00 2001 From: reluc Date: Fri, 28 Nov 2025 16:00:04 +0100 Subject: [PATCH 12/21] chore: fix critical audit warnings --- package-lock.json | 119 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 89 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 06e7e5a07..2757a9136 100644 --- a/package-lock.json +++ b/package-lock.json @@ -362,6 +362,29 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "dev": true, @@ -462,10 +485,10 @@ }, "node_modules/@jsep-plugin/assignment": { "version": "1.3.0", - "license": "MIT", "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10.16.0" }, @@ -475,10 +498,10 @@ }, "node_modules/@jsep-plugin/regex": { "version": "1.0.4", - "license": "MIT", "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10.16.0" }, @@ -2146,6 +2169,7 @@ }, "node_modules/acorn": { "version": "8.15.0", + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3687,9 +3711,9 @@ }, "node_modules/debug": { "version": "4.4.0", - "license": "MIT", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -4806,11 +4830,11 @@ } }, "node_modules/express": { - "dev": true, - "license": "MIT", "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5230,13 +5254,16 @@ } }, "node_modules/form-data": { - "version": "4.0.2", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -5300,6 +5327,21 @@ "version": "1.0.0", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -5441,7 +5483,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -6504,7 +6548,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -6520,10 +6566,10 @@ }, "node_modules/jsep": { "version": "1.4.0", - "license": "MIT", "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10.16.0" } @@ -6571,7 +6617,9 @@ } }, "node_modules/json-schema-ref-parser/node_modules/js-yaml": { - "version": "3.14.1", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -6664,7 +6712,9 @@ } }, "node_modules/koa": { - "version": "2.16.1", + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.3.tgz", + "integrity": "sha512-zPPuIt+ku1iCpFBRwseMcPYQ1cJL8l60rSmKeOuGfOXyE6YnTBmf2aEFNL2HQGrD0cPcLO/t+v9RTgC+fwEh/g==", "dev": true, "license": "MIT", "dependencies": { @@ -7482,11 +7532,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/nise/node_modules/path-to-regexp": { - "version": "6.3.0", - "dev": true, - "license": "MIT" - }, "node_modules/node-addon-api": { "version": "7.0.0", "license": "MIT" @@ -9125,11 +9170,13 @@ } }, "node_modules/playwright": { - "version": "1.52.0", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.52.0" + "playwright-core": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -9142,7 +9189,9 @@ } }, "node_modules/playwright-core": { - "version": "1.52.0", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -10783,7 +10832,9 @@ } }, "node_modules/tar-fs": { - "version": "3.0.8", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, "license": "MIT", "dependencies": { @@ -11192,7 +11243,9 @@ } }, "node_modules/tslint/node_modules/js-yaml": { - "version": "3.14.1", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -12357,13 +12410,15 @@ } }, "packages/browser-bundle/node_modules/glob": { - "version": "11.0.2", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -12379,7 +12434,9 @@ } }, "packages/browser-bundle/node_modules/jackspeak": { - "version": "4.1.0", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -12401,11 +12458,13 @@ } }, "packages/browser-bundle/node_modules/minimatch": { - "version": "10.0.1", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { "node": "20 || >=22" From f116cbc981894dcc9f47f86708146717d30ff937 Mon Sep 17 00:00:00 2001 From: reluc Date: Fri, 28 Nov 2025 16:37:22 +0100 Subject: [PATCH 13/21] chore: fix eslint warnings --- packages/cli/src/cli.ts | 1 + packages/cli/src/executor.ts | 1 + packages/cli/src/script-runner.ts | 2 +- packages/cli/test/parsers/config-params-parser.ts | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d1387f71f..cbe9a6169 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -110,6 +110,7 @@ program .command("schema") .description("prints the json schema for the configuration file") .action(() => { + // eslint-disable-next-line no-console console.log(JSON.stringify(ConfigSchema, null, 2)); }); diff --git a/packages/cli/src/executor.ts b/packages/cli/src/executor.ts index f7abb433a..474ee88ce 100644 --- a/packages/cli/src/executor.ts +++ b/packages/cli/src/executor.ts @@ -43,6 +43,7 @@ export class Executor { return require(file); } } catch (error) { + // eslint-disable-next-line no-console console.error("Error running WoT script:", error); process.exit(1); } diff --git a/packages/cli/src/script-runner.ts b/packages/cli/src/script-runner.ts index ab651c3ef..f0da9b19b 100644 --- a/packages/cli/src/script-runner.ts +++ b/packages/cli/src/script-runner.ts @@ -46,7 +46,7 @@ export async function runScripts(context: WoTContext, scripts: string[], debug?: } }); }; - // eslint-disable-next-line @typescript-eslint/no-var-requires + if (debug && debug.shouldBreak) { // Activate inspector only if is not already opened and wait for the debugger to attach inspector.url() == null && inspector.open(debug.port, debug.host, true); diff --git a/packages/cli/test/parsers/config-params-parser.ts b/packages/cli/test/parsers/config-params-parser.ts index 86d860399..8bd8b5b9b 100644 --- a/packages/cli/test/parsers/config-params-parser.ts +++ b/packages/cli/test/parsers/config-params-parser.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /******************************************************************************** * Copyright (c) 2025 Contributors to the Eclipse Foundation * From 5718765dd6d6ec7dcfb0749dd1693314dfc4fa69 Mon Sep 17 00:00:00 2001 From: reluc Date: Fri, 28 Nov 2025 16:39:02 +0100 Subject: [PATCH 14/21] style: run format --- packages/cli/eslint.config.mjs | 5 +---- packages/cli/src/configuration.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index 14857d013..fb2f8f1ab 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -1,7 +1,4 @@ import { defineConfig, globalIgnores } from "eslint/config"; import baseConfig from "../../eslint.config.mjs"; -export default defineConfig([ - baseConfig, - globalIgnores(["src/generated/**.ts", "./import-json.js"]) -]) +export default defineConfig([baseConfig, globalIgnores(["src/generated/**.ts", "./import-json.js"])]); diff --git a/packages/cli/src/configuration.ts b/packages/cli/src/configuration.ts index a39e10783..ceaf077f9 100644 --- a/packages/cli/src/configuration.ts +++ b/packages/cli/src/configuration.ts @@ -32,14 +32,14 @@ type Mutable = { -readonly [K in keyof T]: Mutable }; type Generalize = T extends number ? number : T extends string - ? string - : T extends boolean - ? boolean - : T extends Array - ? Array> - : T extends object - ? { [K in keyof T]: Generalize } - : T; + ? string + : T extends boolean + ? boolean + : T extends Array + ? Array> + : T extends object + ? { [K in keyof T]: Generalize } + : T; export type Configuration = FromSchema; export const defaultConfiguration = Object.freeze({ From e5ddb9bb05ff9794c407a0ac5b9e86201df80dd0 Mon Sep 17 00:00:00 2001 From: reluc Date: Thu, 18 Dec 2025 18:54:40 +0100 Subject: [PATCH 15/21] test(cli): use tmp library for script files --- package-lock.json | 21 +++++++++++++- packages/cli/package.json | 4 ++- packages/cli/test/executor.ts | 53 ++++++++++++----------------------- 3 files changed, 41 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2757a9136..7d3f864eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1657,6 +1657,13 @@ "@types/node": "*" } }, + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "license": "MIT" @@ -10943,6 +10950,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "dev": true, @@ -12539,7 +12556,9 @@ }, "devDependencies": { "@types/lodash": "^4.14.199", - "json-schema-to-ts": "^3.1.1" + "@types/tmp": "^0.2.6", + "json-schema-to-ts": "^3.1.1", + "tmp": "^0.2.5" }, "optionalDependencies": { "ts-node": "10.9.1" diff --git a/packages/cli/package.json b/packages/cli/package.json index 08582f77c..f286fc25f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,6 +49,8 @@ "keywords": [], "devDependencies": { "@types/lodash": "^4.14.199", - "json-schema-to-ts": "^3.1.1" + "@types/tmp": "^0.2.6", + "json-schema-to-ts": "^3.1.1", + "tmp": "^0.2.5" } } diff --git a/packages/cli/test/executor.ts b/packages/cli/test/executor.ts index e1b39d6ac..78eaff1be 100644 --- a/packages/cli/test/executor.ts +++ b/packages/cli/test/executor.ts @@ -16,21 +16,21 @@ import { suite, test } from "@testdeck/mocha"; import { should, expect } from "chai"; import { Executor, WoTContext } from "../src/executor"; -import { writeFileSync, unlinkSync } from "fs"; -import { join } from "path"; +import { writeFileSync } from "fs"; import { Helpers } from "@node-wot/core"; +import tmp from "tmp"; should(); @suite("Executor") class ExecutorTest { private executor!: Executor; - private testFilePath!: string; + private basetTestFilePath!: string; private mockWoTContext!: WoTContext; before() { + tmp.setGracefulCleanup(); this.executor = new Executor(); - this.testFilePath = join(__dirname, "./resources", "test-script-" + Date.now()); this.mockWoTContext = { // We are not using WoT inside this testing scripts runtime: {} as typeof WoT, @@ -38,61 +38,44 @@ class ExecutorTest { }; } - after() { - try { - unlinkSync(this.testFilePath + ".js"); - } catch { - // File may not exist - } - try { - unlinkSync(this.testFilePath + ".mjs"); - } catch { - // File may not exist - } - try { - unlinkSync(this.testFilePath + ".ts"); - } catch { - // File may not exist - } - try { - unlinkSync(this.testFilePath + ".tsx"); - } catch { - // File may not exist - } - } + after() {} @test async "should execute JavaScript file"() { const scriptContent = "module.exports = 'test result';"; - writeFileSync(this.testFilePath + ".js", scriptContent); + const testFile = tmp.fileSync({ postfix: ".js" }).name; + writeFileSync(testFile, scriptContent); - const result = await this.executor.exec(this.testFilePath + ".js", this.mockWoTContext); + const result = await this.executor.exec(testFile, this.mockWoTContext); expect(result).to.equal("test result"); } @test async "should have WoT defined"() { const scriptContent = "module.exports = typeof global.WoT !== 'undefined';"; - writeFileSync(this.testFilePath + ".js", scriptContent); + const testFile = tmp.fileSync({ postfix: ".js" }).name; + writeFileSync(testFile, scriptContent); - const result = await this.executor.exec(this.testFilePath + ".js", this.mockWoTContext); + const result = await this.executor.exec(testFile, this.mockWoTContext); expect(result).to.be.true; } @test async "should handle module exports"() { const scriptContent = "module.exports = { message: 'hello' };"; - writeFileSync(this.testFilePath + ".js", scriptContent); + const testFile = tmp.fileSync({ postfix: ".js" }).name; + writeFileSync(testFile, scriptContent); - const result = await this.executor.exec(this.testFilePath + ".js", this.mockWoTContext); + const result = await this.executor.exec(testFile, this.mockWoTContext); expect(result).to.have.property("message", "hello"); } @test async "should detect TypeScript files by .ts extension"() { const scriptContent = "export const value: number = 42;"; - writeFileSync(this.testFilePath + ".ts", scriptContent); + const testFile = tmp.fileSync({ postfix: ".ts" }).name; + writeFileSync(testFile, scriptContent); - const { value } = (await this.executor.exec(this.testFilePath + ".ts", this.mockWoTContext)) as { + const { value } = (await this.executor.exec(testFile, this.mockWoTContext)) as { value: number; }; @@ -100,7 +83,7 @@ class ExecutorTest { } @test async "should handle .mjs files as ES modules"() { - const filePath = this.testFilePath + ".mjs"; + const filePath = tmp.fileSync({ postfix: ".mjs" }).name; const scriptContent = "export const value = 'es module';"; writeFileSync(filePath, scriptContent); From ca12fe449c625f8acab70cd7bddbb1512f01d783 Mon Sep 17 00:00:00 2001 From: Cristiano Aguzzi Date: Wed, 7 Jan 2026 16:31:05 +0100 Subject: [PATCH 16/21] refactor(cli): basic error messages improvments Co-authored-by: danielpeintner --- packages/binding-mqtt/src/mqtt-broker-server.ts | 2 +- packages/cli/src/parsers/config-file-parser.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/binding-mqtt/src/mqtt-broker-server.ts b/packages/binding-mqtt/src/mqtt-broker-server.ts index 0a1335f41..8484a9647 100644 --- a/packages/binding-mqtt/src/mqtt-broker-server.ts +++ b/packages/binding-mqtt/src/mqtt-broker-server.ts @@ -446,7 +446,7 @@ export default class MqttBrokerServer implements ProtocolServer { private async startBroker() { return new Promise((resolve, reject) => { if (this.brokerURI == null) { - throw new Error("Unexpected configuration state broker was started even if brokerURI is null"); + throw new Error("Unexpected configuration state. Broker was started but brokerURI is null"); } this.hostedServer = Server({}); diff --git a/packages/cli/src/parsers/config-file-parser.ts b/packages/cli/src/parsers/config-file-parser.ts index b64644acb..4afc7889a 100644 --- a/packages/cli/src/parsers/config-file-parser.ts +++ b/packages/cli/src/parsers/config-file-parser.ts @@ -26,6 +26,6 @@ export function parseConfigFile(filename: string, previous: unknown) { if (err instanceof InvalidArgumentError) { throw err; } - throw new InvalidArgumentError(`\nError reading config file: ${err}`); + throw new InvalidArgumentError(`Error reading config file: ${err}`); } } From 61768ae9632dfeec35d679b2018343468d631dca Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 7 Jan 2026 16:37:47 +0100 Subject: [PATCH 17/21] chore(cli): rename import-json.js to import-json-to-ts.js --- package.json | 2 +- packages/cli/{import-json.js => import-json-to-ts.js} | 0 packages/cli/package.json | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) rename packages/cli/{import-json.js => import-json-to-ts.js} (100%) diff --git a/package.json b/package.json index 4a058c234..30c1c285c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ } }, "scripts": { - "build": "npm run build:json -w packages/cli && tsc -b && npm run build -w packages/browser-bundle", + "build": "npm run build:transform -w packages/cli && tsc -b && npm run build -w packages/browser-bundle", "pretest": "npm run build", "start": "cd packages/cli && npm run start", "debug": "cd packages/cli && npm run debug", diff --git a/packages/cli/import-json.js b/packages/cli/import-json-to-ts.js similarity index 100% rename from packages/cli/import-json.js rename to packages/cli/import-json-to-ts.js diff --git a/packages/cli/package.json b/packages/cli/package.json index f286fc25f..d887abd52 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -33,8 +33,8 @@ "lodash": "^4.17.21" }, "scripts": { - "build:json": "node import-json.js", - "build": "npm run build:json && tsc -b", + "build:transform": "node import-json-to-ts.js", + "build": "npm run build:transform && tsc -b", "start": "ts-node src/cli.ts", "debug": "node -r ts-node/register --inspect-brk=9229 src/cli.ts", "lint": "eslint .", From eed1f940fbb7a6d89c0e1382a4c2a31c5dd538a0 Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 7 Jan 2026 16:45:41 +0100 Subject: [PATCH 18/21] feat(cli): remove now unsupported inspect options With the new changes we are not using a vm anymore, so user can directly use node inspect options. --- packages/cli/src/cli.ts | 4 +- packages/cli/src/parsers/index.ts | 1 - packages/cli/src/parsers/ip-parser.ts | 23 ---------- packages/cli/test/parsers/ip-parser.ts | 62 -------------------------- 4 files changed, 1 insertion(+), 89 deletions(-) delete mode 100644 packages/cli/src/parsers/ip-parser.ts delete mode 100644 packages/cli/test/parsers/ip-parser.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cbe9a6169..23464f6e4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -26,7 +26,7 @@ import { createLoggers, Helpers } from "@node-wot/core"; import { loadEnvVariables } from "./utils"; import { runScripts } from "./script-runner"; import { readdir } from "fs/promises"; -import { parseConfigFile, parseConfigParams, parseIp } from "./parsers"; +import { parseConfigFile, parseConfigParams } from "./parsers"; import { setLogLevel } from "./utils/set-log-level"; import { buildConfig, buildConfigFromFile, Configuration, defaultConfiguration } from "./configuration"; import { cloneDeep } from "lodash"; @@ -77,8 +77,6 @@ In your configuration files you can the following to enable IDE config validatio // CLI options declaration program - .option("-i, --inspect [host]:[port]", "activate inspector on host:port (default: 127.0.0.1:9229)", parseIp) - .option("-ib, --inspect-brk [host]:[port]", "activate inspector on host:port (default: 127.0.0.1:9229)", parseIp) .option("-c, --client-only", "do not start any servers (enables multiple instances without port conflicts)") .option("-cp, --compiler ", "load module as a compiler") .addOption( diff --git a/packages/cli/src/parsers/index.ts b/packages/cli/src/parsers/index.ts index 236c2aae3..d5a56826c 100644 --- a/packages/cli/src/parsers/index.ts +++ b/packages/cli/src/parsers/index.ts @@ -14,4 +14,3 @@ ********************************************************************************/ export * from "./config-file-parser"; export * from "./config-params-parser"; -export * from "./ip-parser"; diff --git a/packages/cli/src/parsers/ip-parser.ts b/packages/cli/src/parsers/ip-parser.ts deleted file mode 100644 index 8c05479b7..000000000 --- a/packages/cli/src/parsers/ip-parser.ts +++ /dev/null @@ -1,23 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2025 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and - * Document License (2015-05-13) which is available at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. - * - * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ -import { InvalidArgumentError } from "commander"; - -export function parseIp(value: string, previous: string) { - if (!/^([a-z]*|[\d.]*)(:[0-9]{2,5})?$/.test(value)) { - throw new InvalidArgumentError("Invalid host:port combo"); - } - - return value; -} diff --git a/packages/cli/test/parsers/ip-parser.ts b/packages/cli/test/parsers/ip-parser.ts deleted file mode 100644 index e55d8b265..000000000 --- a/packages/cli/test/parsers/ip-parser.ts +++ /dev/null @@ -1,62 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2025 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and - * Document License (2015-05-13) which is available at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. - * - * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ - -import { suite, test } from "@testdeck/mocha"; -import { should, expect } from "chai"; -import { parseIp } from "../../src/parsers/ip-parser"; -import { InvalidArgumentError } from "commander"; - -should(); - -@suite("parseIp parser") -class IpParserTest { - @test "should parse valid IP address with port"() { - const result = parseIp("127.0.0.1:9229", ""); - expect(result).to.equal("127.0.0.1:9229"); - } - - @test "should parse valid hostname with port"() { - const result = parseIp("localhost:8080", ""); - expect(result).to.equal("localhost:8080"); - } - - @test "should parse valid port only"() { - const result = parseIp(":9229", ""); - expect(result).to.equal(":9229"); - } - - @test "should parse IP address without port"() { - const result = parseIp("192.168.1.1", ""); - expect(result).to.equal("192.168.1.1"); - } - - @test "should throw error for invalid format"() { - expect(() => parseIp("invalid@address:9229", "")).to.throw(InvalidArgumentError); - } - - @test "should throw error for port too short"() { - expect(() => parseIp("127.0.0.1:1", "")).to.throw(InvalidArgumentError); - } - - @test "should accept single-char hostname"() { - const result = parseIp("a:9229", ""); - expect(result).to.equal("a:9229"); - } - - @test "should accept full IP address ranges"() { - const result = parseIp("192.168.0.255:65535", ""); - expect(result).to.equal("192.168.0.255:65535"); - } -} From 876bede093749ae49b4ba209a2553e2e7ca768b6 Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 7 Jan 2026 16:48:55 +0100 Subject: [PATCH 19/21] feat(cli): remove compiler option --- packages/cli/src/cli.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 23464f6e4..de70c19ff 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -78,7 +78,6 @@ In your configuration files you can the following to enable IDE config validatio // CLI options declaration program .option("-c, --client-only", "do not start any servers (enables multiple instances without port conflicts)") - .option("-cp, --compiler ", "load module as a compiler") .addOption( new Option( "-ll, --logLevel ", From 9a2f46155de08cee12a17b63c4ca3d4277312ca2 Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 7 Jan 2026 16:55:07 +0100 Subject: [PATCH 20/21] docs(cli/README): allign the help message with the latest changes --- packages/cli/README.md | 91 +++++++++++++---------------------------- packages/cli/src/cli.ts | 2 +- 2 files changed, 29 insertions(+), 64 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 9dcfac40f..0bf892c03 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -56,79 +56,44 @@ The `-h` option explains the functionality and also how node-wot can be configur The `-h` help option shows the following output: ``` -Usage: wot-servient [options] [files...] +Usage: wot-servient [options] [command] [files...] Run a WoT Servient in the current directory. Arguments: - files script files to execute. If no script is given, all .js files in the current directory are - loaded. If one or more script is given, these files are loaded instead of the directory. + files script files to execute. If no script is given, all .js files in the current directory are loaded. If one or more script is given, these files are loaded instead of the directory. Options: - -v, --version display node-wot version - -i, --inspect [host]:[port] activate inspector on host:port (default: 127.0.0.1:9229) - -ib, --inspect-brk [host]:[port] activate inspector on host:port (default: 127.0.0.1:9229) - -c, --client-only do not start any servers (enables multiple instances without port conflicts) - -cp, --compiler load module as a compiler - -f, --config-file load configuration from specified file (default: "wot-servient.conf.json") - -p, --config-params override configuration parameters [key1:=value1 key2:=value2 ...] (e.g. http.port:=8080) - -h, --help show this help - -wot-servient.conf.json syntax: + -v, --version display node-wot version + -c, --client-only do not start any servers (enables multiple instances without port conflicts) + -ll, --logLevel choose the desired log level. WARNING: if DEBUG env variable is specified this option gets overridden. (choices: "debug", "info", "warn", "error") + -f, --config-file load configuration from specified file (default: $(pwd)/wot-servient.conf.json + -p, --config-params override configuration parameters [key1:=value1 key2:=value2 ...] (e.g. http.port:=8080) + -h, --help show this help + +Commands: + schema prints the json schema for the configuration file + +Configuration + +Settings can be applied through three methods, in order of precedence (highest to lowest): + +1. Command-Line Parameters (-p path.to.set=value) +2. Environment Variables (NODE_WOT_PATH_TO_SET=value) (supports .env files too) +3. Configuration File + +For the complete list of available configuration fields and their data types, run: + +wot-servient schema + +In your configuration files you can the following to enable IDE config validation: + { - "servient": { - "clientOnly": CLIENTONLY, - "staticAddress": STATIC, - "scriptAction": RUNSCRIPT - }, - "http": { - "port": HPORT, - "proxy": PROXY, - "allowSelfSigned": ALLOW - }, - "mqtt" : { - "broker": BROKER-URL, - "username": BROKER-USERNAME, - "password": BROKER-PASSWORD, - "clientId": BROKER-UNIQUEID, - "protocolVersion": MQTT_VERSION - }, - "credentials": { - THING_ID1: { - "token": TOKEN - }, - THING_ID2: { - "username": USERNAME, - "password": PASSWORD - } - } + "$schema": "./node_modules/@node-wot/cli/dist/wot-servient-schema.conf.json" + ... } - -wot-servient.conf.json fields: - CLIENTONLY : boolean setting if no servers shall be started (default=false) - STATIC : string with hostname or IP literal for static address config - RUNSCRIPT : boolean to activate the 'runScript' Action (default=false) - HPORT : integer defining the HTTP listening port - PROXY : object with "href" field for the proxy URI, - "scheme" field for either "basic" or "bearer", and - corresponding credential fields as defined below - ALLOW : boolean whether self-signed certificates should be allowed - BROKER-URL : URL to an MQTT broker that publisher and subscribers will use - BROKER-UNIQUEID : unique id set by MQTT client while connecting to the broker - MQTT_VERSION : number indicating the MQTT protocol version to be used (3, 4, or 5) - THING_IDx : string with TD "id" for which credentials should be configured - TOKEN : string for providing a Bearer token - USERNAME : string for providing a Basic Auth username - PASSWORD : string for providing a Basic Auth password - --------------------------------------------------------------------------- - -Environment variables must be provided in a .env file in the current working directory. - -Example: -VAR1=Value1 -VAR2=Value2 ``` Additionally, you can look at [the JSON Schema](https://github.com/eclipse-thingweb/node-wot/blob/master/packages/cli/src/wot-servient-schema.conf.json) to understand possible values for each field. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index de70c19ff..2acd45953 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -86,7 +86,7 @@ program ) .option( "-f, --config-file ", - "load configuration from specified file (default: $(pwd)/wot-servient.conf.json", + "load configuration from specified file (default: $(pwd)/wot-servient.conf.json)", (value, previous) => parseConfigFile(value, previous) ) .option( From c1597d67be7a2b1b72d8c2182cf7629a3903aee3 Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 7 Jan 2026 16:59:30 +0100 Subject: [PATCH 21/21] docs(cli/README): add missing closing parentesis --- packages/cli/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 0bf892c03..25be6c347 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -69,7 +69,7 @@ Options: -v, --version display node-wot version -c, --client-only do not start any servers (enables multiple instances without port conflicts) -ll, --logLevel choose the desired log level. WARNING: if DEBUG env variable is specified this option gets overridden. (choices: "debug", "info", "warn", "error") - -f, --config-file load configuration from specified file (default: $(pwd)/wot-servient.conf.json + -f, --config-file load configuration from specified file (default: $(pwd)/wot-servient.conf.json) -p, --config-params override configuration parameters [key1:=value1 key2:=value2 ...] (e.g. http.port:=8080) -h, --help show this help