@ix-xs/node-comfort 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/src/Arr.js +13 -5
  5. package/src/Checker.js +56 -8
  6. package/src/Cli.js +60 -18
  7. package/src/Color.js +32 -6
  8. package/src/Crypto.js +429 -78
  9. package/src/Emitter.js +9 -9
  10. package/src/Env.js +90 -8
  11. package/src/FS.js +366 -56
  12. package/src/Http.js +263 -72
  13. package/src/Id.js +53 -15
  14. package/src/Logger.js +100 -22
  15. package/src/Num.js +2 -1
  16. package/src/Obj.js +225 -36
  17. package/src/SQLite.js +154 -97
  18. package/src/Schema.js +281 -40
  19. package/src/Str.js +77 -29
  20. package/src/Sys.js +123 -22
  21. package/src/Time.js +96 -22
  22. package/src/Utils.js +18 -3
  23. package/src/errors.js +75 -4
  24. package/src/internal/shared.js +68 -14
  25. package/src/internal/terminal.js +49 -0
  26. package/types/index.d.ts +28 -5
  27. package/types/src/Arr.d.ts +4 -2
  28. package/types/src/Arr.d.ts.map +1 -1
  29. package/types/src/Checker.d.ts +29 -3
  30. package/types/src/Checker.d.ts.map +1 -1
  31. package/types/src/Cli.d.ts +6 -4
  32. package/types/src/Cli.d.ts.map +1 -1
  33. package/types/src/Color.d.ts +2 -2
  34. package/types/src/Color.d.ts.map +1 -1
  35. package/types/src/Crypto.d.ts +126 -28
  36. package/types/src/Crypto.d.ts.map +1 -1
  37. package/types/src/Emitter.d.ts +6 -6
  38. package/types/src/Emitter.d.ts.map +1 -1
  39. package/types/src/Env.d.ts +19 -1
  40. package/types/src/Env.d.ts.map +1 -1
  41. package/types/src/FS.d.ts +27 -20
  42. package/types/src/FS.d.ts.map +1 -1
  43. package/types/src/Http.d.ts +42 -5
  44. package/types/src/Http.d.ts.map +1 -1
  45. package/types/src/Id.d.ts.map +1 -1
  46. package/types/src/Logger.d.ts +11 -3
  47. package/types/src/Logger.d.ts.map +1 -1
  48. package/types/src/Num.d.ts.map +1 -1
  49. package/types/src/Obj.d.ts +15 -2
  50. package/types/src/Obj.d.ts.map +1 -1
  51. package/types/src/SQLite.d.ts +104 -12
  52. package/types/src/SQLite.d.ts.map +1 -1
  53. package/types/src/Schema.d.ts +61 -5
  54. package/types/src/Schema.d.ts.map +1 -1
  55. package/types/src/Str.d.ts +32 -7
  56. package/types/src/Str.d.ts.map +1 -1
  57. package/types/src/Sys.d.ts +42 -9
  58. package/types/src/Sys.d.ts.map +1 -1
  59. package/types/src/Time.d.ts +10 -5
  60. package/types/src/Time.d.ts.map +1 -1
  61. package/types/src/Utils.d.ts +16 -6
  62. package/types/src/Utils.d.ts.map +1 -1
  63. package/types/src/errors.d.ts +6 -2
  64. package/types/src/errors.d.ts.map +1 -1
  65. package/types/src/internal/shared.d.ts +7 -0
  66. package/types/src/internal/shared.d.ts.map +1 -1
  67. package/types/src/internal/terminal.d.ts +13 -0
  68. package/types/src/internal/terminal.d.ts.map +1 -0
package/src/Logger.js CHANGED
@@ -7,7 +7,15 @@
7
7
  * files if you need to.
8
8
  *
9
9
  * Markup: `<% red bold Error:%> disk is full`. Any style name works, plus
10
- * `#ff8800`, `bg#1e1e2e`, `rgb(255, 128, 0)` and `bgRgb(...)`.
10
+ * `#ff8800`, `bg#1e1e2e`, `rgb(255, 128, 0)` and `bgRgb(...)`. Only the
11
+ * first argument, the message, is read as markup: the values after it are
12
+ * printed as they are.
13
+ *
14
+ * What a terminal could act on is shown as text (`\x1b`, `\r`), so a logged
15
+ * value can't move the cursor, change the title or forge a log line. Colors
16
+ * from `nc.color`, links from `color.link()`, new lines and tabs still work,
17
+ * and the lines of a message after its first one are indented, except with
18
+ * `log()` when there is no scope and no timestamp.
11
19
  *
12
20
  * Levels, from chatty to severe: `trace`, `debug`, `info`, `warn`, `error`,
13
21
  * `fatal`. The default is `info`; `LOG_LEVEL` changes it. `DEBUG=1` shows
@@ -44,6 +52,7 @@ const { timeConfig } = require("./internal/shared.js");
44
52
  * @property {string} path The file to append to. Folders are created, colors removed.
45
53
  * @property {string | number} [maxSize] Rotate past this size, like `"10MB"`.
46
54
  * @property {number} [maxFiles] How many rotated files to keep. Defaults to `5`.
55
+ * @property {number} [mode] The permissions a new log file gets, like `0o640`. Defaults to `0o600`: only its owner can read it. A file that already exists keeps its own. Ignored on Windows.
47
56
  */
48
57
 
49
58
  /**
@@ -78,7 +87,7 @@ const { timeConfig } = require("./internal/shared.js");
78
87
  * A logger from `createLogger()`. Every method returns the logger, so calls chain.
79
88
  *
80
89
  * @typedef {object} Logger
81
- * @property {(...args: unknown[]) => Logger} log A plain message, no badge. Supports markup.
90
+ * @property {(...args: unknown[]) => Logger} log A plain message, no badge. Its first argument supports markup.
82
91
  * @property {(...args: unknown[]) => Logger} trace Very detailed diagnostics, hidden unless the level is `trace`.
83
92
  * @property {(...args: unknown[]) => Logger} debug Diagnostics, hidden unless the level is `debug` or lower.
84
93
  * @property {(...args: unknown[]) => Logger} info An `ℹ INFO` message.
@@ -124,6 +133,13 @@ const STYLE_NAMES = new Set([
124
133
  ]);
125
134
 
126
135
  const REDACTED = "[redacted]";
136
+ // Shows control characters and escape sequences as text, keeps colors and links.
137
+ const { safe: _safe } = require("./internal/terminal.js");
138
+ // What JSON.stringify leaves as is, but a terminal or a JavaScript parser may act on.
139
+ const JSON_UNSAFE = /[\x7f-\x9f\u{2028}\u{2029}]/u;
140
+ const JSON_UNSAFE_ALL = /[\x7f-\x9f\u{2028}\u{2029}]/gu;
141
+ /** @param {string} char */
142
+ const _jsonEscape = (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
127
143
  const TRIM_LINES = /^(\s*\r?\n)+|(\r?\n\s*)+$/g;
128
144
  const RGB = /(bg)?rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)/gi;
129
145
  const TOKEN = /^(\S+)(\s+|$)/;
@@ -227,7 +243,7 @@ const _markup = (input, delimiter, palette) => {
227
243
  * @returns {unknown}
228
244
  */
229
245
  const _redact = (value, secrets, seen) => {
230
- if (value === null || typeof value !== "object" || value instanceof Date || value instanceof RegExp || value instanceof Error || ArrayBuffer.isView(value)) return value;
246
+ if (value === null || typeof value !== "object" || value instanceof Date || value instanceof RegExp || ArrayBuffer.isView(value)) return value;
231
247
  const known = seen.get(value);
232
248
  if (known !== undefined) return known;
233
249
  if (Array.isArray(value)) {
@@ -237,7 +253,44 @@ const _redact = (value, secrets, seen) => {
237
253
  for (const item of value) list.push(_redact(item, secrets, seen));
238
254
  return list;
239
255
  }
240
- if (value instanceof Map || value instanceof Set) return value;
256
+ if (value instanceof Map) {
257
+ // Same class name in the output, values masked by their key.
258
+ /** @type {Map<unknown, unknown>} */
259
+ const map = Object.setPrototypeOf(new Map(), Object.getPrototypeOf(value));
260
+ seen.set(value, map);
261
+ for (const [key, item] of Map.prototype.entries.call(value)) {
262
+ map.set(_redact(key, secrets, seen), typeof key === "string" && secrets.has(key.toLowerCase()) ? REDACTED : _redact(item, secrets, seen));
263
+ }
264
+ return map;
265
+ }
266
+ if (value instanceof Set) {
267
+ /** @type {Set<unknown>} */
268
+ const set = Object.setPrototypeOf(new Set(), Object.getPrototypeOf(value));
269
+ seen.set(value, set);
270
+ for (const item of Set.prototype.values.call(value)) set.add(_redact(item, secrets, seen));
271
+ return set;
272
+ }
273
+ if (value instanceof Error) {
274
+ // Its own properties too (the cause, a code, a response), stack and message kept as they are.
275
+ /** @type {Record<string | symbol, unknown>} */
276
+ const error = Object.create(Object.getPrototypeOf(value));
277
+ seen.set(value, error);
278
+ for (const key of Reflect.ownKeys(value)) {
279
+ const descriptor = /** @type {PropertyDescriptor} */ (Object.getOwnPropertyDescriptor(value, key));
280
+ let item;
281
+ try {
282
+ item = /** @type {any} */ (value)[key];
283
+ } catch {
284
+ continue;
285
+ }
286
+ const secret = typeof key === "string" && secrets.has(key.toLowerCase());
287
+ const keep = key === "stack" || key === "message" || key === "name";
288
+ Object.defineProperty(error, key, { value: secret ? REDACTED : keep ? item : _redact(item, secrets, seen), enumerable: descriptor.enumerable, writable: true, configurable: true });
289
+ }
290
+ // The stack lives on the original, not on the copy: carry it over.
291
+ if (!Object.hasOwn(error, "stack")) Object.defineProperty(error, "stack", { value: value.stack, writable: true, configurable: true });
292
+ return error;
293
+ }
241
294
  /** @type {Record<string, unknown>} */
242
295
  const copy = Object.create(Object.getPrototypeOf(value));
243
296
  seen.set(value, copy);
@@ -260,7 +313,7 @@ const _stringify = (value, colors, secrets) => {
260
313
  let cause = /** @type {any} */ (value).cause;
261
314
  let depth = 0;
262
315
  while (cause !== undefined && depth++ < 5) {
263
- text += `\nCaused by: ${cause instanceof Error ? cause.stack ?? cause.message : util.inspect(cause, { colors })}`;
316
+ text += `\nCaused by: ${cause instanceof Error ? cause.stack ?? cause.message : util.inspect(secrets ? _redact(cause, secrets, new Map()) : cause, { colors })}`;
264
317
  cause = cause instanceof Error ? /** @type {any} */ (cause).cause : undefined;
265
318
  }
266
319
  return text;
@@ -290,8 +343,9 @@ const _stringify = (value, colors, secrets) => {
290
343
  * @returns {string}
291
344
  */
292
345
  const _json = (level, args, scope, fields, secrets, now) => {
346
+ const time = now.toISOString();
293
347
  /** @type {Record<string, unknown>} */
294
- const record = { time: now.toISOString(), level, ...fields };
348
+ const record = { time, level, ...fields };
295
349
  if (scope) record.scope = scope;
296
350
  const messages = [];
297
351
  for (const arg of args) {
@@ -303,17 +357,25 @@ const _json = (level, args, scope, fields, secrets, now) => {
303
357
  messages.push(typeof arg === "string" ? color.strip(arg) : _stringify(arg, false, secrets));
304
358
  }
305
359
  }
360
+ // A logged object may carry a `time`, `level`, `scope` or `msg` of its own: the logger's win.
361
+ record.time = time;
362
+ record.level = level;
363
+ if (scope) record.scope = scope;
364
+ else delete record.scope;
306
365
  record.msg = messages.join(" ");
366
+ let line;
307
367
  try {
308
- return JSON.stringify(record, (key, v) => (secrets?.has(key.toLowerCase()) ? REDACTED : typeof v === "bigint" ? v.toString() : v));
368
+ line = JSON.stringify(record, (key, v) => (secrets?.has(key.toLowerCase()) ? REDACTED : typeof v === "bigint" ? v.toString() : v));
309
369
  } catch {
310
- return JSON.stringify({ time: record.time, level, msg: `${record.msg} [unserializable fields]` });
370
+ line = JSON.stringify({ time, level, msg: `${record.msg} [unserializable fields]` });
311
371
  }
372
+ // C1 controls and line separators stay raw in JSON: escape them too.
373
+ return JSON_UNSAFE.test(line) ? line.replace(JSON_UNSAFE_ALL, _jsonEscape) : line;
312
374
  };
313
375
 
314
376
  /**
315
377
  * A log file: where it is, how big it may get, how big it is now.
316
- * @typedef {{ target: string, limit: number | undefined, keep: number, size: number, ready: boolean }} _LogFile
378
+ * @typedef {{ target: string, limit: number | undefined, keep: number, size: number, ready: boolean, options: { mode: number } }} _LogFile
317
379
  */
318
380
 
319
381
  /**
@@ -326,6 +388,8 @@ const _openFile = (file) => ({
326
388
  keep: Math.max(1, file.maxFiles ?? 5),
327
389
  size: 0,
328
390
  ready: false,
391
+ // Logs can hold personal data: a new file is for its owner only.
392
+ options: { mode: file.mode ?? 0o600 },
329
393
  });
330
394
 
331
395
  /** @param {string} target */
@@ -365,11 +429,11 @@ const _writeFile = (file, line) => {
365
429
  }
366
430
  }
367
431
  try {
368
- fs.appendFileSync(file.target, text);
432
+ fs.appendFileSync(file.target, text, file.options);
369
433
  } catch {
370
434
  // The folder was removed since: make it again, once.
371
435
  fs.mkdirSync(path.dirname(file.target), { recursive: true });
372
- fs.appendFileSync(file.target, text);
436
+ fs.appendFileSync(file.target, text, file.options);
373
437
  file.size = 0;
374
438
  }
375
439
  file.size += bytes;
@@ -464,7 +528,7 @@ const _create = (options) => {
464
528
  let face = faces[which];
465
529
  if (face) return face;
466
530
  const palette = paletteOf(which);
467
- face = { palette, colors: palette.level > 0, labels: {}, scope: settings.scope ? palette.magenta(`[${settings.scope}]`) : "", stamp: "", stampAt: -1, stampWidth: 0 };
531
+ face = { palette, colors: palette.level > 0, labels: {}, scope: settings.scope ? palette.magenta(`[${_safe(settings.scope, false)}]`) : "", stamp: "", stampAt: -1, stampWidth: 0 };
468
532
  for (const kind of Object.keys(BADGES)) face.labels[kind] = BADGES[kind].label ? _style(palette, BADGES[kind].style.split(" "), BADGES[kind].label) : "";
469
533
  faces[which] = face;
470
534
  return face;
@@ -493,13 +557,18 @@ const _create = (options) => {
493
557
  * @param {unknown[]} args
494
558
  * @param {Date} now
495
559
  * @param {boolean} stamped
560
+ * @param {boolean} block A table or a box, drawn by the CLI: no markup, lines kept in place.
496
561
  * @returns {string}
497
562
  */
498
- const render = (face, kind, args, now, stamped) => {
563
+ const render = (face, kind, args, now, stamped, block) => {
499
564
  const { palette } = face;
500
565
  let body = "";
501
566
  for (let i = 0; i < args.length; i++) {
502
- const text = _markup(_stringify(args[i], face.colors, secrets), delimiter, palette);
567
+ const arg = args[i];
568
+ // Whatever is logged, a terminal must not act on it: only colors and links get through.
569
+ let text = _safe(_stringify(arg, face.colors, secrets), face.colors);
570
+ // Markup is for the message the developer wrote, never for the values after it.
571
+ if (i === 0 && !block && typeof arg === "string") text = _markup(text, delimiter, palette);
503
572
  body += (i ? " " : "") + (face.colors && (kind === "debug" || kind === "trace") ? palette.gray(text) : text);
504
573
  }
505
574
  let prefix = "";
@@ -517,6 +586,12 @@ const _create = (options) => {
517
586
  if (face.labels[kind]) prefix += `${face.labels[kind]} `;
518
587
  if (body.indexOf("\n") === -1) return (prefix + body).trimEnd();
519
588
  const lines = body.replace(TRIM_LINES, "").split(/\r?\n/);
589
+ // Without a timestamp, the next lines line up under the message, past the badge and
590
+ // the scope: a new line in a message can't pass for a log line of its own. Without
591
+ // either, like `log()`, there is nothing to forge and nothing is added, as with console.log.
592
+ if (!stamped && !block) {
593
+ padWidth += (face.scope ? color.width(face.scope) + 1 : 0) + (face.labels[kind] ? color.width(face.labels[kind]) + 1 : 0);
594
+ }
520
595
  const pad = " ".repeat(padWidth);
521
596
  let out = (prefix + lines[0]).trimEnd();
522
597
  for (let i = 1; i < lines.length; i++) out += `\n${pad}${lines[i]}`;
@@ -526,8 +601,9 @@ const _create = (options) => {
526
601
  /**
527
602
  * @param {keyof typeof BADGES} kind
528
603
  * @param {unknown[]} args
604
+ * @param {boolean} [block] A table or a box drawn by the CLI.
529
605
  */
530
- const emit = (kind, args) => {
606
+ const emit = (kind, args, block = false) => {
531
607
  if (!on[kind]) return;
532
608
  const badge = BADGES[kind];
533
609
  const now = new Date();
@@ -538,15 +614,15 @@ const _create = (options) => {
538
614
  if (file) _writeFile(file, line);
539
615
  } else {
540
616
  const face = faceOf(badge.stream);
541
- const line = render(face, kind, args, now, Boolean(settings.timestamp));
617
+ const line = render(face, kind, args, now, Boolean(settings.timestamp), block);
542
618
  stream.write(`${line}\n`);
543
619
  // The file gets the same line without colors: the one just made, when it has none.
544
- if (file) _writeFile(file, face.colors ? render(faceOf("file"), kind, args, now, Boolean(settings.timestamp)) : line);
620
+ if (file) _writeFile(file, face.colors ? render(faceOf("file"), kind, args, now, Boolean(settings.timestamp), block) : line);
545
621
  }
546
622
  if (settings.onLog) {
547
623
  try {
548
624
  /** @type {unknown} */
549
- const pending = settings.onLog({ time: now, level: badge.level, kind: /** @type {LogEntry["kind"]} */ (kind), scope: settings.scope, message: render(faceOf("file"), kind, args, now, false), args });
625
+ const pending = settings.onLog({ time: now, level: badge.level, kind: /** @type {LogEntry["kind"]} */ (kind), scope: settings.scope, message: render(faceOf("file"), kind, args, now, false, block), args });
550
626
  // An async hook that fails would otherwise be an unhandled rejection.
551
627
  if (pending && typeof (/** @type {any} */ (pending).then) === "function") /** @type {PromiseLike<unknown>} */ (pending).then(undefined, () => {});
552
628
  } catch {
@@ -587,8 +663,9 @@ const _create = (options) => {
587
663
  const ms = Number(process.hrtime.bigint() - start) / 1e6;
588
664
  return logger.log(`${label}: ${ms < 1000 ? `${Math.round(ms * 100) / 100}ms` : require("./Time.js").formatDuration(ms, { units: 2 })}`);
589
665
  },
590
- table: (rows, tableOptions) => logger.log(require("./Cli.js").table(rows, { ...tableOptions, colors: faceOf("stdout").colors })),
591
- box: (text, boxOptions) => logger.log(require("./Cli.js").box(text, { ...boxOptions, colors: faceOf("stdout").colors })),
666
+ // Drawn by the CLI from values: no markup in them.
667
+ table: (rows, tableOptions) => (on.log && emit("log", [require("./Cli.js").table(rows, { ...tableOptions, colors: faceOf("stdout").colors })], true), logger),
668
+ box: (text, boxOptions) => (on.log && emit("log", [require("./Cli.js").box(text, { ...boxOptions, colors: faceOf("stdout").colors })], true), logger),
592
669
  divider: (title) => {
593
670
  const columns = /** @type {any} */ (streamOf("stdout")).columns ?? 80;
594
671
  const width = Math.max(10, Math.min(columns, 100) - (settings.timestamp ? 11 : 0) - groups.length * 2);
@@ -651,8 +728,9 @@ function createLogger(options = {}) {
651
728
  }
652
729
 
653
730
  /**
654
- * Prints a message without a badge. Strings support markup, objects are
655
- * pretty-printed, and several arguments are joined with spaces.
731
+ * Prints a message without a badge. The first argument, when it's a string,
732
+ * supports markup; objects are pretty-printed, and several arguments are
733
+ * joined with spaces.
656
734
  *
657
735
  * @example
658
736
  * nc.log("<% green ✔ Saved%> in <% bold 12ms%>");
package/src/Num.js CHANGED
@@ -703,7 +703,8 @@ function parse(value, fallback = NaN) {
703
703
  */
704
704
  function parseBytes(value, options = {}) {
705
705
  if (typeof value === "number") return value;
706
- const match = /^\s*([-+]?(?:\d+\.?\d*|\.\d+))\s*([kmgtpezy]?)(i?)(b?)(?:ytes?)?\s*$/i.exec(String(value));
706
+ // Trimmed first: spaces at both ends of the pattern would be tried against each other.
707
+ const match = /^([-+]?(?:\d+(?:\.\d*)?|\.\d+))\s*([kmgtpezy]?)(i?)(b?)(?:ytes?)?$/i.exec(String(value).trim());
707
708
  if (!match) return NaN;
708
709
  const [, amount, prefix, iec] = match;
709
710
  const base = iec || options.binary !== false ? 1024 : 1000;