-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.js
388 lines (361 loc) · 14.2 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
// index.js
const path = require("path");
const fs = require("fs");
// This is for re-exporting
const allUtils = {
...require("./cli-primer_modules/argTools"),
...require("./cli-primer_modules/configTools"),
...require("./cli-primer_modules/utils"),
...require("./cli-primer_modules/session"),
};
// This is for convenient local usage.
const {
mergeData,
deepMergeData,
monitoringFn,
setDebugMode,
getAppInfo,
getUserHomeDirectory,
getDefaultBanner,
ensureSetup,
} = require("./cli-primer_modules/utils");
const { getArguments, getHelp } = require("./cli-primer_modules/argTools");
const {
getConfigData,
initializeConfig,
} = require("./cli-primer_modules/configTools");
const {
canOpenSession,
openSession,
closeSession,
} = require("./cli-primer_modules/session");
/**
* Convenience entry point to set up cli-primer, wrap it around your main application logic, and
* execute it, in one go.
*
* NOTE: if you use `wrapAndRun` you can still access any of the individual utils functions defined
* in the other modules, as needed. This function does not rule them out, and is here just for your
* convenience. You can choose not to use `wrapAndRun` at all, and instead employ cli-primer tools
* in your own style.
*
* @param {Object} setupData
* An Object that helps tailoring the `cli-primer` package utilities to your application
* needs. Expected structure is:
* {
* // Whether to include "debug" messages in console output; applies to default
* // monitoring function only.
* showDebugMessages: false,
*
* // Whether you need support for an `output directory`, useful if your application
* // is meant to produce local content to be stored on disk.
* useOutputDir: false,
*
* // Whether to employ the basic session control cli-primer provides; useful if your
* // app involves lengthy operations that would suffer from execution overlapping.
* useSessionControl: false,
*
* // Whether to employ the configuration file system provided by cli-primer. This is
* // a home directory based config file with support for profiles and data validation.
* useConfig: false,
*
* // Whether to employ the `--help` argument the cli-primer provides. Giving that at
* // app run will display info about all available arguments and exit early.
* useHelp: false,
*
* // Array ob Object, with each Object describing a known command line argument. See
* // documentation of function `getArguments` in module `argTools.js` for details.
* argsDictionary: [],
*
* // Key-value pairs to act as absolute defaults for any of the known arguments. These
* // defaults apply when neither config file nor command line provide any overrides.
* intrinsicDefaults: {},
*
* // A template to use when `useConfig` is `true`. Must be valid JSON, and will be
* // injected with a `profiles` section for you. You can use placeholders, e.g.,
* // "{{name}}" would resolve to the app name, as defined in the `package.json`.
* // See `getAppInfo` in `utils.js` for available app placeholders and `initializeConfig`
* // in `configTools.js` for default configuration file details.
* configTemplate: "",
*
* // Object describing an initial files and folders structure to be created for you in
* // the output directory, used if `useOutputDir` is true. See `ensureSetup` in `utils.js`
* // for the exact format.
* outputDirBlueprint: {},
* }
*
*
* @param {Function} mainFn
* Mandatory; a function that executes your application code, i.e., the main entry point
* into your logic. It must have the signature:
* `Number myMainFn (inputData, utils, monitoringFn)`
*
* where `inputData` is an Object with all input gathered via configuration file and/or
* command line arguments, and `utils` is an Object providing convenient access to all
* utility functions defined by the cli-primer package. As for `monitoringFn`, it receives
* the monitoring function in use, either the default one, or provided by you via
* `userMonitoringFn` (see next).
*
* You are expected to return a Number out of your `mainFn`: `0` for normal exit, `1` for
* early, expected exit, and `2` for early, unexpected exit (aka, an error occurred). This
* value will be picked-up and returned by `wrapAndRun`, for you to use.
*
* Note that your `mainFn` can also return a Promise that resolves to these numbers, this is
* supported as well.
*
* @param {Function} cleanupFn
* Optional; a function to be called when application is about to exit as a result of
* SIGINT or SIGTERM. It must have the signature:
* `Number onCleanup (inputData, utils, monitoringFn)`
*
* where `inputData` is an Object with all input gathered via configuration file and/or
* command line arguments, and `utils` is an Object providing convenient access to all
* utility functions defined by the cli-primer package. As for `monitoringFn`, it receives
* the monitoring function in use, either the default one, or provided by you via
* `userMonitoringFn` (see next).
*
* NOTE: you don't need to call `process.exit()` from your `cleanupFn`. `cli-primer` will
* call it for you, passing it the return value of your `cleanupFn`.
*
*
* @param {Function} userMonitoringFn
* Optional function to receive real-time monitoring information. If not provided, the
* `monitoringFn` function defined in the `utils.js` module will be used. For details,
* see its own documentation there.
*
* @returns {Promise<Number>} Returns a Promise that resolves to:
* - `0`: To suggest that the program completed normally.
* - `1`: To suggest that the program had an expected early exit (e.g., the `--help`
* argument was given, which printed documentation and halted execution).
* - `2`: To suggest the program exited due to an error.
*
* NOTES:
* 1. If no errors prevent `cli-primer` from running the `mainFn` you provide, then the
* Promise will resolve to whatever value your `mainFn` returns. You can follow the same
* convention, or you can devise your own.
* 2. `cli-primer` itself does not call `process.exit` at the end of a normal operation
* cycle, but it is probably a good idea that you call it, passing it the return value of
* `wrapAndRun`, as these (0, 1, 2) are some pretty common exit values, and you will gain
* interoperability by doing so (you will be able to run your application from, e.g., bash
* scripts, which will know for sure whether your application terminated normally or not).
* `cli-primer` does not call `process.exit` itself to give you flexibility. For example,
* if you intend to keep a service running in your application, you could return a
* different value (e.g., `3`) from your `mainFn` and handle that case separately in your
* code (e.g., choose NOT to call `process.exit()`).
*/
async function wrapAndRun(
setupData = {},
mainFn,
cleanupFn = null,
userMonitoringFn = null
) {
return new Promise(async (resolve, reject) => {
const defaults = {
showDebugMessages: false,
useOutputDir: false,
useSessionControl: false,
useConfig: false,
useHelp: false,
argsDictionary: [],
intrinsicDefaults: {},
configTemplate: "",
outputDirBlueprint: {},
};
// Merge defaultSetupData with user-provided setupData
const finalSetupData = deepMergeData(defaults, setupData);
// Setup monitoring function
const $m = userMonitoringFn || monitoringFn;
setDebugMode(finalSetupData.showDebugMessages);
// Prepare arguments dictionary
const argsDictionary = [...finalSetupData.argsDictionary];
if (finalSetupData.useHelp) {
argsDictionary.push({
name: "Help",
payload: /^--(help|h)$/,
doc: "Displays information about the program's input parameters and exits.",
mandatory: false,
});
}
if (finalSetupData.useConfig) {
argsDictionary.push(
{
name: "Configuration File Initialization",
payload: /^--(init_config|ic)$/,
doc: "Initializes an empty configuration file in the user's home directory and exits.",
mandatory: false,
},
{
name: "Configuration Profile Selection",
payload: /^--(config_profile|cp)=(.+)$/,
doc: "Loads default data from a configuration profile if it has been defined.",
mandatory: false,
}
);
}
if (finalSetupData.useOutputDir) {
argsDictionary.push({
name: "Output directory",
payload: /^--(output_dir|od)=(.+)$/,
doc: "The working directory for the program. All content produced by the program will be placed in this directory. It must be an absolute and valid path to an already existing folder.",
mandatory: true,
});
}
// 1. Parse command-line arguments
const appInfo = getAppInfo($m);
const cmdArgs = getArguments(argsDictionary, null, $m);
if (!cmdArgs) {
$m({
type: "error",
message: `Failed reading program arguments. Run "${appInfo.name} --h" for documentation.`,
});
resolve(2); // Error exit
return;
}
// 2. Handle --help
if (finalSetupData.useHelp && cmdArgs.help) {
console.log(
`${getDefaultBanner(appInfo)}\n${getHelp(argsDictionary, $m)}`
);
return;
}
// 3. Handle --init_config
const configFilePath = path.join(
getUserHomeDirectory(),
`${appInfo.appPathName}.config`
);
if (finalSetupData.useConfig && cmdArgs.init_config) {
initializeConfig(
configFilePath,
finalSetupData.configTemplate,
appInfo,
$m
);
$m({
type: "debug",
message: `Configuration file initialized at ${configFilePath}`,
});
resolve(1); // Early exit for config initialization
return;
}
// 4. Load default profile
const defaultProfileData = finalSetupData.useConfig
? getConfigData(configFilePath, "default", argsDictionary, $m)
: {};
// 5. Load specified profile if any
const specifiedProfileData =
finalSetupData.useConfig && cmdArgs.config_profile
? getConfigData(
configFilePath,
cmdArgs.config_profile,
argsDictionary,
$m
)
: {};
// 6. Merge all data sources
const mergedData = mergeData(
finalSetupData.intrinsicDefaults,
defaultProfileData,
specifiedProfileData,
cmdArgs
);
// 7. Validate mandatory arguments
for (const { payload, mandatory } of argsDictionary) {
if (mandatory) {
const argName = payload.source.match(/\(([^)]+)\)/)[1].split("|")[0];
if (!mergedData[argName]) {
$m({
type: "error",
message: `Mandatory argument "${argName}" is missing.`,
});
resolve(2);
return;
}
}
}
// 8. Ensure the output directory is given and valid
if (finalSetupData.useOutputDir) {
const outputDir = mergedData.output_dir;
if (
!outputDir ||
!fs.existsSync(outputDir) ||
!fs.lstatSync(outputDir).isDirectory()
) {
$m({
type: "error",
message: `Provided output directory "${outputDir}" is invalid.`,
});
resolve(2);
return;
}
}
// 9. Check for session control
if (finalSetupData.useOutputDir && finalSetupData.useSessionControl) {
const outputDir = mergedData.output_dir;
if (!canOpenSession(outputDir)) {
$m({
type: "error",
message: `Session is already active for the directory: ${outputDir}`,
});
resolve(2);
return;
}
openSession(outputDir);
}
// 10. Ensure the output directory structure
if (finalSetupData.useOutputDir) {
ensureSetup(mergedData.output_dir, finalSetupData.outputDirBlueprint, $m);
}
// 11. Execute the main business logic
let mainExitVal = 0;
try {
mainExitVal = await mainFn(mergedData, allUtils, $m);
} catch (error) {
$m({
type: "error",
message: `Error in main function execution: ${error.message}`,
data: { error },
});
mainExitVal = 2;
} finally {
if (finalSetupData.useOutputDir && finalSetupData.useSessionControl) {
closeSession(mergedData.output_dir);
}
resolve(mainExitVal);
}
// 12. HANDLE CLEANUP ON EXIT
// Define generic listener.
const onExit = (signalType, inputData, utils, monitoringFn) => {
monitoringFn({
type: "debug",
message: `Exiting by signal ${signalType}.`,
});
if (finalSetupData.useOutputDir && finalSetupData.useSessionControl) {
monitoringFn({
type: "debug",
message: `Session control is employed. Cleaning up before exit.`,
});
closeSession(inputData.output_dir);
}
let termExitVal = 1;
if (cleanupFn) {
termExitVal = cleanupFn(inputData, utils, monitoringFn);
}
process.exit (termExitVal);
};
// Factory to build a closure out of the generic listener
function createExitHandler(signalType, inputData, utils, monitoringFn) {
return () => onExit(signalType, inputData, utils, monitoringFn);
}
// Hook up listeners.
process.on("SIGINT", createExitHandler("SIGINT", mergedData, allUtils, $m));
process.on(
"SIGTERM",
createExitHandler("SIGTERM", mergedData, allUtils, $m)
);
// ...Promise function ends here.
});
}
// Re-exporting all the functions for convenient, individual access, if needed.
module.exports = {
wrapAndRun,
...allUtils,
};