Skip to content

Commit ee16e04

Browse files
committed
refactor(cli): better code organization and minor improvements logging
cli.ts log now enables by default error and warning messages.
1 parent abb813c commit ee16e04

12 files changed

Lines changed: 381 additions & 251 deletions

package-lock.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@thingweb/thing-model": "^1.0.4",
2929
"ajv": "^8.11.0",
3030
"commander": "^9.1.0",
31+
"debug": "^4.4.0",
3132
"dotenv": "^16.4.7",
3233
"lodash": "^4.17.21"
3334
},

packages/cli/src/cli.ts

Lines changed: 47 additions & 251 deletions
Original file line numberDiff line numberDiff line change
@@ -14,31 +14,30 @@
1414
********************************************************************************/
1515

1616
// default implementation of W3C WoT Servient (http(s) and file bindings)
17-
import DefaultServient from "./cli-default-servient";
18-
import ErrnoException = NodeJS.ErrnoException;
17+
import DefaultServient, { ScriptOptions } from "./cli-default-servient";
1918

2019
// tools
21-
import fs = require("fs");
22-
import * as dotenv from "dotenv";
2320
import * as path from "path";
24-
import { Command, InvalidArgumentError, Argument } from "commander";
25-
import Ajv, { ValidateFunction, ErrorObject } from "ajv";
21+
import { Command, Argument } from "commander";
22+
import Ajv, { ValidateFunction } from "ajv";
2623
import ConfigSchema from "./wot-servient-schema.conf.json";
27-
import _ from "lodash";
2824
import { version } from "@node-wot/core/package.json";
2925
import { createLoggers } from "@node-wot/core";
30-
import inspector from "inspector";
26+
import { buildConfig } from "./config-builder";
27+
import { loadCompiler, loadEnvVariables } from "./utils";
28+
import { runScripts } from "./script-runner";
29+
import { readdir } from "fs/promises";
30+
import * as logger from "debug";
31+
import { parseConfigFile, parseConfigParams, parseIp } from "./parsers";
3132

32-
const { error, info, warn } = createLoggers("cli", "cli");
33+
const { error, info, warn, debug } = createLoggers("cli", "cli");
3334

3435
const program = new Command();
3536
const ajv = new Ajv({ strict: true });
3637
const schemaValidator = ajv.compile(ConfigSchema) as ValidateFunction;
3738
const defaultFile = "wot-servient.conf.json";
3839
const baseDir = ".";
3940

40-
const dotEnvConfigParameters: DotEnvConfigParameter = {};
41-
4241
// General commands
4342
program
4443
.name("wot-servient")
@@ -115,87 +114,19 @@ VAR1=Value1
115114
VAR2=Value2`
116115
);
117116

118-
// Typings
119-
type DotEnvConfigParameter = {
120-
[key: string]: unknown;
121-
};
122-
interface DebugParams {
123-
shouldBreak: boolean;
124-
host: string;
125-
port: number;
126-
}
127-
128-
// Parsers & validators
129-
function parseIp(value: string, previous: string) {
130-
if (!/^([a-z]*|[\d.]*)(:[0-9]{2,5})?$/.test(value)) {
131-
throw new InvalidArgumentError("Invalid host:port combo");
132-
}
133-
134-
return value;
135-
}
136-
function parseConfigFile(filename: string, previous: string) {
137-
try {
138-
const open = filename || path.join(baseDir, defaultFile);
139-
const data = fs.readFileSync(open, "utf-8");
140-
if (!schemaValidator(JSON.parse(data))) {
141-
throw new InvalidArgumentError(
142-
`Config file contains invalid an JSON: ${(schemaValidator.errors ?? [])
143-
.map((o: ErrorObject) => o.message)
144-
.join("\n")}`
145-
);
146-
}
147-
return filename;
148-
} catch (err) {
149-
throw new InvalidArgumentError(`Error reading config file: ${err}`);
150-
}
151-
}
152-
function parseConfigParams(param: string, previous: unknown) {
153-
// Validate key-value pair
154-
if (!/^([a-zA-Z0-9_.]+):=([a-zA-Z0-9_]+)$/.test(param)) {
155-
throw new InvalidArgumentError("Invalid key-value pair");
156-
}
157-
const fieldNamePath = param.split(":=")[0];
158-
const fieldNameValue = param.split(":=")[1];
159-
let fieldNameValueCast;
160-
if (Number(fieldNameValue)) {
161-
fieldNameValueCast = +fieldNameValue;
162-
} else if (fieldNameValue === "true" || fieldNameValue === "false") {
163-
fieldNameValueCast = Boolean(fieldNameValue);
164-
} else {
165-
fieldNameValueCast = fieldNamePath;
166-
}
167-
168-
// Build object using dot-notation JSON path
169-
const obj = _.set({}, fieldNamePath, fieldNameValueCast);
170-
if (!schemaValidator(obj)) {
171-
throw new InvalidArgumentError(
172-
`Config parameter '${param}' is not valid: ${(schemaValidator.errors ?? [])
173-
.map((o: ErrorObject) => o.message)
174-
.join("\n")}`
175-
);
176-
}
177-
// Concatenate validated parameters
178-
let result = previous ?? {};
179-
result = _.merge(result, obj);
180-
return result;
181-
}
182-
183117
// CLI options declaration
184118
program
185119
.option("-i, --inspect [host]:[port]", "activate inspector on host:port (default: 127.0.0.1:9229)", parseIp)
186120
.option("-ib, --inspect-brk [host]:[port]", "activate inspector on host:port (default: 127.0.0.1:9229)", parseIp)
187121
.option("-c, --client-only", "do not start any servers (enables multiple instances without port conflicts)")
188122
.option("-cp, --compiler <module>", "load module as a compiler")
189-
.option(
190-
"-f, --config-file <file>",
191-
"load configuration from specified file",
192-
parseConfigFile,
193-
"wot-servient.conf.json"
123+
.option("-f, --config-file <file>", "load configuration from specified file", (value, previous) =>
124+
parseConfigFile(value, previous, schemaValidator)
194125
)
195126
.option(
196127
"-p, --config-params <param...>",
197128
"override configuration parameters [key1:=value1 key2:=value2 ...] (e.g. http.port:=8080)",
198-
parseConfigParams
129+
(value, previous) => parseConfigParams(value, previous, schemaValidator)
199130
);
200131

201132
// CLI arguments
@@ -206,189 +137,54 @@ program.addArgument(
206137
)
207138
);
208139

209-
program.parse(process.argv);
210-
const options = program.opts();
211-
const args = program.args;
212-
213-
// .env parsing
214-
const env: dotenv.DotenvConfigOutput = dotenv.config();
215-
const errorNoException: ErrnoException | undefined = env.error;
216-
if (errorNoException?.code !== "ENOENT") {
217-
throw env.error;
218-
} else if (env.parsed) {
219-
for (const [key, value] of Object.entries(env.parsed)) {
220-
// Parse and validate on configfile-related entries
221-
if (key.startsWith("config.")) {
222-
dotEnvConfigParameters[key.replace("config.", "")] = value;
223-
}
140+
program.action(async function (_, options, cmd) {
141+
if (process.env.DEBUG == null) {
142+
// by default enable error logs and warnings
143+
// user can override it using DEBUG env
144+
logger.enable("node-wot:**:error");
145+
logger.enable("node-wot:**:warn");
224146
}
225-
}
226147

227-
// Functions
228-
async function buildConfig(): Promise<unknown> {
229-
const fileToOpen = options?.configFile ?? path.join(baseDir, defaultFile);
230-
let configFileData = {};
148+
const args = cmd.args;
149+
const env = loadEnvVariables();
150+
const defaultFilePath = path.join(baseDir, defaultFile);
151+
let servient: DefaultServient;
152+
153+
debug("command line options %O", options);
154+
debug("command line arguments %O", args);
155+
debug("command line environment variables", args);
231156

232-
// JSON config file
233157
try {
234-
configFileData = JSON.parse(await fs.promises.readFile(fileToOpen, "utf-8"));
158+
const config = await buildConfig(options, defaultFilePath, env);
159+
servient = new DefaultServient(options.clientOnly, config);
235160
} catch (err) {
236-
error(`WoT-Servient config file error: ${err}`);
237-
}
238-
239-
// .env file
240-
for (const [key, value] of Object.entries(dotEnvConfigParameters)) {
241-
const obj = _.set({}, key, value);
242-
configFileData = _.merge(configFileData, obj);
243-
}
244-
245-
// CLI arguments
246-
if (options?.configParams != null) {
247-
configFileData = _.merge(configFileData, options.configParams);
248-
}
249-
250-
return configFileData;
251-
}
252-
const loadCompilerFunction = function (compilerModule: string | undefined) {
253-
if (compilerModule != null) {
254-
const compilerMod = require(compilerModule);
255-
256-
if (compilerMod.create == null) {
257-
throw new Error("No create function defined for " + compilerModule);
161+
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT" || options.configFile != null) {
162+
error("WoT-Servient config file error. %O", err);
163+
process.exit((err as NodeJS.ErrnoException).errno ?? 1);
258164
}
259165

260-
const compilerObject = compilerMod.create();
261-
262-
if (compilerObject.compile == null) {
263-
throw new Error("No compile function defined for create return object");
264-
}
265-
return compilerObject.compile;
266-
}
267-
return undefined;
268-
};
269-
const loadEnvVariables = function () {
270-
const env: dotenv.DotenvConfigOutput = dotenv.config();
271-
272-
const errorNoException: ErrnoException | undefined = env.error;
273-
// ignore file not found but throw otherwise
274-
if (errorNoException?.code !== "ENOENT") {
275-
throw env.error;
166+
warn(`WoT-Servient using defaults as %s does not exist`, defaultFile);
167+
servient = new DefaultServient(options.clientOnly);
276168
}
277-
return env;
278-
};
279-
280-
const runScripts = async function (servient: DefaultServient, scripts: Array<string>, debug?: DebugParams) {
281-
const env = loadEnvVariables();
282169

283-
const launchScripts = (scripts: Array<string>) => {
284-
const compile = loadCompilerFunction(options.compiler);
285-
scripts.forEach((fname: string) => {
286-
info(`WoT-Servient reading script ${fname}`);
287-
fs.readFile(fname, "utf8", (err, data) => {
288-
if (err) {
289-
error(`WoT-Servient experienced error while reading script. ${err}`);
290-
} else {
291-
// limit printout to first line
292-
info(
293-
`WoT-Servient running script '${data.substr(0, data.indexOf("\n")).replace("\r", "")}'... (${
294-
data.split(/\r\n|\r|\n/).length
295-
} lines)`
296-
);
170+
await servient.start();
297171

298-
fname = path.resolve(fname);
299-
servient.runScript(data, fname, {
300-
argv: args,
301-
env: env.parsed,
302-
compiler: compile,
303-
});
304-
}
305-
});
306-
});
172+
const scriptOptions: ScriptOptions = {
173+
env,
174+
argv: args,
175+
compiler: loadCompiler(options.compiler),
307176
};
308177

309-
if (debug && debug.shouldBreak) {
310-
// Activate inspector only if is not already opened and wait for the debugger to attach
311-
inspector.url() == null && inspector.open(debug.port, debug.host, true);
312-
313-
// Set a breakpoint at the first line of of first script
314-
// the breakpoint gives time to inspector clients to set their breakpoints
315-
const session = new inspector.Session();
316-
session.connect();
317-
session.post("Debugger.enable", (error: Error) => {
318-
if (error != null) {
319-
warn("Cannot set breakpoint; reason: cannot enable debugger");
320-
warn(error.toString());
321-
}
322-
323-
session.post(
324-
"Debugger.setBreakpointByUrl",
325-
{
326-
lineNumber: 0,
327-
url: "file:///" + path.resolve(scripts[0]).replace(/\\/g, "/"),
328-
},
329-
(err: Error | null) => {
330-
if (err != null) {
331-
warn("Cannot set breakpoint");
332-
warn(error.toString());
333-
}
334-
launchScripts(scripts);
335-
}
336-
);
337-
});
338-
} else {
339-
// Activate inspector only if is not already opened and don't wait
340-
debug != null && inspector.url() == null && inspector.open(debug.port, debug.host, false);
341-
launchScripts(scripts);
178+
if (args.length > 0) {
179+
return runScripts(servient, args, scriptOptions, options.inspect ?? options.inspectBrk);
342180
}
343-
};
344181

345-
const runAllScripts = function (servient: DefaultServient, debug?: DebugParams) {
346-
fs.readdir(baseDir, (err, files) => {
347-
if (err) {
348-
warn(`WoT-Servient experienced error while loading directory. ${err}`);
349-
return;
350-
}
182+
const files = await readdir(baseDir);
183+
const scripts = files.filter((file) => !file.startsWith(".") && file.slice(-3) === ".js");
351184

352-
// unhidden .js files
353-
const scripts = files.filter((file) => {
354-
return file.substr(0, 1) !== "." && file.slice(-3) === ".js";
355-
});
356-
info(`WoT-Servient using current directory with ${scripts.length} script${scripts.length > 1 ? "s" : ""}`);
185+
info(`WoT-Servient using current directory with %d script${scripts.length > 1 ? "s" : ""}`, scripts.length);
357186

358-
runScripts(
359-
servient,
360-
scripts.map((filename) => path.resolve(path.join(baseDir, filename))),
361-
debug
362-
);
363-
});
364-
};
187+
return runScripts(servient, args, scriptOptions, options.inspect ?? options.inspectBrk);
188+
});
365189

366-
buildConfig()
367-
.then((conf) => {
368-
return new DefaultServient(options.clientOnly, conf);
369-
})
370-
.catch((err) => {
371-
if (err.code === "ENOENT" && options.configFile == null) {
372-
warn(`WoT-Servient using defaults as '${defaultFile}' does not exist`);
373-
return new DefaultServient(options.clientOnly);
374-
} else {
375-
error(`"WoT-Servient config file error. ${err}`);
376-
process.exit(err.errno);
377-
}
378-
})
379-
.then((servient) => {
380-
servient
381-
.start()
382-
.then(() => {
383-
if (args.length > 0) {
384-
info(`WoT-Servient loading ${args.length} command line script${args.length > 1 ? "s" : ""}`);
385-
return runScripts(servient, args, options.inspect ?? options.inspectBrk);
386-
} else {
387-
return runAllScripts(servient, options.inspect ?? options.inspectBrk);
388-
}
389-
})
390-
.catch((err) => {
391-
error(`WoT-Servient cannot start. ${err}`);
392-
});
393-
})
394-
.catch((err) => error(`WoT-Servient main error. ${err}`));
190+
program.parse(process.argv);

0 commit comments

Comments
 (0)