@brianbuie/node-kit 0.12.4 → 0.13.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.
package/src/Dir.ts CHANGED
@@ -3,20 +3,28 @@ import * as path from 'node:path';
3
3
  import sanitizeFilename from 'sanitize-filename';
4
4
  import { File } from './File.ts';
5
5
 
6
+ export type DirOptions = {
7
+ temp?: boolean;
8
+ };
9
+
6
10
  /**
7
11
  * Reference to a specific directory with methods to create and list files.
8
- * Default path: '.'
9
- * > Created on file system the first time .path is read or any methods are used
12
+ * @param inputPath
13
+ * The path of the directory, created on file system the first time `.path` is read or any methods are used
14
+ * @param options
15
+ * include `{ temp: true }` to enable the `.clear()` method
10
16
  */
11
17
  export class Dir {
12
18
  #inputPath;
13
19
  #resolved?: string;
20
+ isTemp;
14
21
 
15
22
  /**
16
23
  * @param path can be relative to workspace or absolute
17
24
  */
18
- constructor(inputPath = '.') {
25
+ constructor(inputPath: string, options: DirOptions = {}) {
19
26
  this.#inputPath = inputPath;
27
+ this.isTemp = Boolean(options.temp);
20
28
  }
21
29
 
22
30
  /**
@@ -32,23 +40,26 @@ export class Dir {
32
40
 
33
41
  /**
34
42
  * Create a new Dir inside the current Dir
35
- * @param subPath joined with parent Dir's path to make new Dir
43
+ * @param subPath
44
+ * joined with parent Dir's path to make new Dir
45
+ * @param options
46
+ * include `{ temp: true }` to enable the `.clear()` method. If current Dir is temporary, child directories will also be temporary.
36
47
  * @example
37
48
  * const folder = new Dir('example');
38
49
  * // folder.path = '/path/to/cwd/example'
39
50
  * const child = folder.dir('path/to/dir');
40
51
  * // child.path = '/path/to/cwd/example/path/to/dir'
41
52
  */
42
- dir(subPath: string) {
43
- return new Dir(path.join(this.path, subPath));
53
+ dir(subPath: string, options: DirOptions = { temp: this.isTemp }) {
54
+ return new (this.constructor as typeof Dir)(path.join(this.path, subPath), options) as this;
44
55
  }
45
56
 
46
57
  /**
47
- * Creates a new TempDir inside current Dir
58
+ * Creates a new temp directory inside current Dir
48
59
  * @param subPath joined with parent Dir's path to make new TempDir
49
60
  */
50
61
  tempDir(subPath: string) {
51
- return new TempDir(path.join(this.path, subPath));
62
+ return this.dir(subPath, { temp: true });
52
63
  }
53
64
 
54
65
  sanitize(filename: string) {
@@ -72,29 +83,21 @@ export class Dir {
72
83
  }
73
84
 
74
85
  get files() {
75
- return fs.readdirSync(this.path).map((filename) => this.file(filename));
76
- }
77
- }
78
-
79
- /**
80
- * Extends Dir class with method to `clear()` contents.
81
- * Default path: `./.temp`
82
- */
83
- export class TempDir extends Dir {
84
- constructor(inputPath = `./.temp`) {
85
- super(inputPath);
86
+ return fs.readdirSync(this.path).map(filename => this.file(filename));
86
87
  }
87
88
 
88
- /**
89
- * > ⚠️ Warning! This deletes the directory!
90
- */
91
89
  clear() {
90
+ if (!this.isTemp) throw new Error('Dir is not temporary');
92
91
  fs.rmSync(this.path, { recursive: true, force: true });
93
92
  fs.mkdirSync(this.path, { recursive: true });
94
93
  }
95
94
  }
96
95
 
97
96
  /**
98
- * './.temp' in current working directory
97
+ * Current working directory
98
+ */
99
+ export const cwd = new Dir('./');
100
+ /**
101
+ * ./.temp in current working directory
99
102
  */
100
- export const temp = new TempDir();
103
+ export const temp = cwd.tempDir('.temp');
package/src/Fetcher.ts CHANGED
@@ -43,7 +43,7 @@ export class Fetcher {
43
43
  Object.entries(mergedOptions.query || {}).forEach(([key, val]) => {
44
44
  if (val === undefined) return;
45
45
  if (Array.isArray(val)) {
46
- val.forEach((v) => {
46
+ val.forEach(v => {
47
47
  params.push([key, `${v}`]);
48
48
  });
49
49
  } else {
@@ -98,15 +98,15 @@ export class Fetcher {
98
98
  attempt++;
99
99
  const [req] = this.buildRequest(route, opts);
100
100
  const res = await fetch(req)
101
- .then((r) => {
101
+ .then(r => {
102
102
  if (!r.ok) throw new Error(r.statusText);
103
103
  return r;
104
104
  })
105
- .catch(async (error) => {
105
+ .catch(async error => {
106
106
  if (attempt < maxAttempts) {
107
107
  const wait = attempt * 3000;
108
108
  console.warn(`${req.method} ${req.url} (attempt ${attempt} of ${maxAttempts})`, error);
109
- await new Promise((resolve) => setTimeout(resolve, wait));
109
+ await new Promise(resolve => setTimeout(resolve, wait));
110
110
  } else {
111
111
  throw new Error(error);
112
112
  }
package/src/File.test.ts CHANGED
@@ -17,7 +17,7 @@ const thing = {
17
17
  describe('File', () => {
18
18
  it('Handles request body as stream input', async () => {
19
19
  const img = testDir.file('image.jpg');
20
- await fetch('https://testingbot.com/free-online-tools/random-avatar/300').then((res) => {
20
+ await fetch('https://testingbot.com/free-online-tools/random-avatar/300').then(res => {
21
21
  if (!res.body) throw new Error('No response body');
22
22
  return img.write(res.body);
23
23
  });
@@ -74,7 +74,7 @@ describe('FileTypeNdjson', () => {
74
74
  assert(file.lines().length === 2);
75
75
  file.append(thing);
76
76
  assert(file.lines().length === 3);
77
- file.lines().forEach((line) => {
77
+ file.lines().forEach(line => {
78
78
  assert.deepStrictEqual(line, thing);
79
79
  });
80
80
  });
@@ -93,7 +93,7 @@ describe('FileTypeCsv', () => {
93
93
  const things = [thing, thing, thing];
94
94
  const file = await testDir.file('csv-data').csv(things);
95
95
  const parsed = await file.read();
96
- parsed.forEach((row) => {
96
+ parsed.forEach(row => {
97
97
  assert.deepEqual(row, thing);
98
98
  });
99
99
  });
package/src/Format.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { format, formatISO, type DateArg } from 'date-fns';
1
+ import { format, formatISO, type DateArg, type Duration } from 'date-fns';
2
+ import formatDuration from 'format-duration';
2
3
 
3
4
  /**
4
5
  * Helpers for formatting dates, times, and numbers as strings
@@ -6,12 +7,25 @@ import { format, formatISO, type DateArg } from 'date-fns';
6
7
  export class Format {
7
8
  /**
8
9
  * date-fns format() with some shortcuts
9
- * @param formatStr
10
- * 'iso' to get ISO date, 'ymd' to format as 'yyyy-MM-dd', full options: https://date-fns.org/v4.1.0/docs/format
10
+ * @param formatStr the format to use
11
+ * @param date the date to format, default `new Date()`
12
+ * @example
13
+ * Format.date('iso') // '2026-04-08T13:56:45Z'
14
+ * Format.date('ymd') // '20260408'
15
+ * Format.date('ymd-hm') // '20260408-1356'
16
+ * Format.date('ymd-hms') // '20260408-135645'
17
+ * Format.date('h:m:s') // '13:56:45'
18
+ * @see more format options https://date-fns.org/v4.1.0/docs/format
11
19
  */
12
- static date(formatStr: 'iso' | 'ymd' | string = 'iso', d: DateArg<Date> = new Date()) {
20
+ static date(
21
+ formatStr: 'iso' | 'ymd' | 'ymd-hm' | 'ymd-hms' | 'h:m:s' | string = 'iso',
22
+ d: DateArg<Date> = new Date(),
23
+ ) {
13
24
  if (formatStr === 'iso') return formatISO(d);
14
- if (formatStr === 'ymd') return format(d, 'yyyy-MM-dd');
25
+ if (formatStr === 'ymd') return format(d, 'yyyyMMdd');
26
+ if (formatStr === 'ymd-hm') return format(d, 'yyyyMMdd-HHmm');
27
+ if (formatStr === 'ymd-hms') return format(d, 'yyyyMMdd-HHmmss');
28
+ if (formatStr === 'h:m:s') return format(d, 'HH:mm:ss');
15
29
  return format(d, formatStr);
16
30
  }
17
31
 
@@ -22,10 +36,19 @@ export class Format {
22
36
  return new Intl.NumberFormat('en-US', { maximumFractionDigits: places }).format(n);
23
37
  }
24
38
 
39
+ static plural(amount: number, singular: string, multiple?: string) {
40
+ return amount === 1 ? `${amount} ${singular}` : `${amount} ${multiple || singular + 's'}`;
41
+ }
42
+
25
43
  /**
26
44
  * Make millisecond durations actually readable (eg "123ms", "3.56s", "1m 34s", "3h 24m", "2d 4h")
45
+ * @param ms milliseconds
46
+ * @param style 'digital' to output as 'HH:MM:SS'
47
+ * @see details on 'digital' format https://github.com/ungoldman/format-duration
48
+ * @see waiting on `Intl.DurationFormat({ style: 'digital' })` types https://github.com/microsoft/TypeScript/issues/60608
27
49
  */
28
- static ms(ms: number) {
50
+ static ms(ms: number, style?: 'digital') {
51
+ if (style === 'digital') return formatDuration(ms, { leading: true });
29
52
  if (ms < 1000) return `${this.round(ms)}ms`;
30
53
  const s = ms / 1000;
31
54
  if (s < 60) return `${this.round(s, 2)}s`;
package/src/Log.test.ts CHANGED
@@ -3,19 +3,6 @@ import assert from 'node:assert';
3
3
  import { Log } from './Log.ts';
4
4
 
5
5
  describe('Log', () => {
6
- it('Throws error', () => {
7
- try {
8
- Log.error('Test error');
9
- } catch (e) {
10
- return;
11
- }
12
- assert(false, 'Did not throw error');
13
- });
14
-
15
- it('Recognizes this is a test', () => {
16
- assert(Log.isTest);
17
- });
18
-
19
6
  it('Uses first argument as message when string', () => {
20
7
  const result = Log.prepare('test', { something: 'else' });
21
8
  assert(result.message === 'test');
package/src/Log.ts CHANGED
@@ -2,7 +2,9 @@ import { inspect } from 'node:util';
2
2
  import { isObjectLike } from 'lodash-es';
3
3
  import chalk, { type ChalkInstance } from 'chalk';
4
4
  import { snapshot } from './snapshot.ts';
5
+ import { Format } from './Format.ts';
5
6
 
7
+ // https://docs.cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity
6
8
  type Severity = 'DEFAULT' | 'DEBUG' | 'INFO' | 'NOTICE' | 'WARNING' | 'ERROR' | 'CRITICAL' | 'ALERT' | 'EMERGENCY';
7
9
 
8
10
  type Options = {
@@ -13,89 +15,112 @@ type Options = {
13
15
  type Entry = {
14
16
  message?: string;
15
17
  severity: Severity;
18
+ stack?: string;
16
19
  details?: unknown[];
17
20
  };
18
21
 
19
22
  export class Log {
20
- // Only silence logs when THIS package is running its own tests
21
- static isTest = process.env.npm_package_name === '@brianbuie/node-kit' && process.env.npm_lifecycle_event === 'test';
23
+ static getStack() {
24
+ const details = { stack: '' };
25
+ // replaces details.stack with current stack trace, excluding this Log.getStack call
26
+ Error.captureStackTrace(details, Log.getStack);
27
+ // remove 'Error' on first line
28
+ return details.stack
29
+ .split('\n')
30
+ .map(l => l.trim())
31
+ .filter(l => l !== 'Error');
32
+ }
22
33
 
23
34
  /**
24
35
  * Gcloud parses JSON in stdout
25
36
  */
26
37
  static #toGcloud(entry: Entry) {
27
- if (entry.details?.length === 1) {
28
- console.log(JSON.stringify(snapshot({ ...entry, details: entry.details[0] })));
29
- } else {
30
- console.log(JSON.stringify(snapshot(entry)));
31
- }
38
+ const details = entry.details?.length === 1 ? entry.details[0] : entry.details;
39
+ const output = { ...entry, details, stack: entry.stack || this.getStack() };
40
+ console.log(JSON.stringify(snapshot(output)));
32
41
  }
33
42
 
34
43
  /**
35
44
  * Includes colors and better inspection for logging during dev
36
45
  */
37
46
  static #toConsole(entry: Entry, color: ChalkInstance) {
38
- if (entry.message) console.log(color(`[${entry.severity}] ${entry.message}`));
39
- entry.details?.forEach((detail) => {
47
+ if (entry.message) console.log(color(`${Format.date('h:m:s')} [${entry.severity}] ${entry.message}`));
48
+ entry.details?.forEach(detail => {
40
49
  console.log(inspect(detail, { depth: 10, breakLength: 100, compact: true, colors: true }));
41
50
  });
42
51
  }
43
52
 
44
- static #log(options: Options, ...input: unknown[]) {
53
+ static #log({ severity, color }: Options, ...input: unknown[]) {
45
54
  const { message, details } = this.prepare(...input);
55
+ const entry: Entry = { message, severity, details };
46
56
  // https://cloud.google.com/run/docs/container-contract#env-vars
47
57
  const isGcloud = process.env.K_SERVICE !== undefined || process.env.CLOUD_RUN_JOB !== undefined;
48
58
  if (isGcloud) {
49
- this.#toGcloud({ message, severity: options.severity, details });
50
- return { message, details, options };
51
- }
52
- // Hide output while testing this package
53
- if (!this.isTest) {
54
- this.#toConsole({ message, severity: options.severity, details }, options.color);
59
+ this.#toGcloud(entry);
60
+ } else {
61
+ this.#toConsole(entry, color);
55
62
  }
56
- return { message, details, options };
63
+ return entry;
57
64
  }
58
65
 
59
66
  /**
60
67
  * Handle first argument being a string or an object with a 'message' prop
61
68
  */
62
69
  static prepare(...input: unknown[]): { message?: string; details: unknown[] } {
63
- let [first, ...rest] = input;
64
- if (typeof first === 'string') {
65
- return { message: first, details: rest };
70
+ let [firstArg, ...rest] = input;
71
+ // First argument is a string, use that as the message
72
+ if (typeof firstArg === 'string') {
73
+ return { message: firstArg, details: rest };
66
74
  }
75
+ // First argument is an object with a `message` property
67
76
  // @ts-ignore
68
- if (isObjectLike(first) && typeof first['message'] === 'string') {
69
- const { message, ...firstDetails } = first as { message: string };
77
+ if (isObjectLike(firstArg) && typeof firstArg['message'] === 'string') {
78
+ const { message, ...firstDetails } = firstArg as { message: string };
70
79
  return { message, details: [firstDetails, ...rest] };
71
80
  }
81
+ // No message found, log all args as details
72
82
  return { details: input };
73
83
  }
74
84
 
75
85
  /**
76
- * Logs error details before throwing
86
+ * Events that require action or attention immediately
87
+ */
88
+ static alert(...input: unknown[]) {
89
+ return this.#log({ severity: 'ALERT', color: chalk.bgRed }, ...input);
90
+ }
91
+
92
+ /**
93
+ * Events that cause problems
77
94
  */
78
95
  static error(...input: unknown[]) {
79
- const { message } = this.#log({ severity: 'ERROR', color: chalk.red }, ...input);
80
- throw new Error(message);
96
+ return this.#log({ severity: 'ERROR', color: chalk.red }, ...input);
81
97
  }
82
98
 
99
+ /**
100
+ * Events that might cause problems
101
+ */
83
102
  static warn(...input: unknown[]) {
84
103
  return this.#log({ severity: 'WARNING', color: chalk.yellow }, ...input);
85
104
  }
86
105
 
106
+ /**
107
+ * Normal but significant events, such as start up, shut down, or a configuration change
108
+ */
87
109
  static notice(...input: unknown[]) {
88
110
  return this.#log({ severity: 'NOTICE', color: chalk.cyan }, ...input);
89
111
  }
90
112
 
113
+ /**
114
+ * Routine information, such as ongoing status or performance
115
+ */
91
116
  static info(...input: unknown[]) {
92
117
  return this.#log({ severity: 'INFO', color: chalk.white }, ...input);
93
118
  }
94
119
 
120
+ /**
121
+ * Debug or trace information
122
+ */
95
123
  static debug(...input: unknown[]) {
96
- const debugging = process.argv.some((arg) => arg.includes('--debug')) || process.env.DEBUG !== undefined;
97
- if (debugging || process.env.NODE_ENV !== 'production') {
98
- return this.#log({ severity: 'DEBUG', color: chalk.gray }, ...input);
99
- }
124
+ return this.#log({ severity: 'DEBUG', color: chalk.gray }, ...input);
100
125
  }
101
126
  }
package/src/TypeWriter.ts CHANGED
@@ -25,7 +25,7 @@ export class TypeWriter {
25
25
  }
26
26
 
27
27
  async addMember(name: string, _samples: any[]) {
28
- const samples = _samples.map((s) => (typeof s === 'string' ? s : JSON.stringify(s)));
28
+ const samples = _samples.map(s => (typeof s === 'string' ? s : JSON.stringify(s)));
29
29
  await this.input.addSource({ name, samples });
30
30
  }
31
31
 
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { Dir, TempDir, temp } from './Dir.ts';
1
+ export { Dir, type DirOptions, temp, cwd } from './Dir.ts';
2
2
  export { Cache } from './Cache.ts';
3
3
  export { Fetcher, type Route, type Query, type FetchOptions } from './Fetcher.ts';
4
4
  export { File, FileType, FileTypeJson, FileTypeNdjson, FileTypeCsv } from './File.ts';
package/src/snapshot.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { isObjectLike } from 'lodash-es';
2
2
 
3
3
  /**
4
- * Allows special objects (Error, Headers, Set) to be included in JSON.stringify output
5
- * functions are removed
4
+ * Allows special objects (Error, Headers, Set) to be included in JSON.stringify output.
5
+ * Functions are removed
6
6
  */
7
7
  export function snapshot(i: unknown, max = 50, depth = 0): any {
8
8
  if (Array.isArray(i)) {
9
9
  if (depth === max) return [];
10
- return i.map((c) => snapshot(c, max, depth + 1));
10
+ return i.map(c => snapshot(c, max, depth + 1));
11
11
  }
12
12
  if (typeof i === 'function') return undefined;
13
13
  if (!isObjectLike(i)) return i;
@@ -32,7 +32,7 @@ export function snapshot(i: unknown, max = 50, depth = 0): any {
32
32
  }
33
33
 
34
34
  // Get Non-enumberable, own properties
35
- Object.getOwnPropertyNames(obj).forEach((key) => {
35
+ Object.getOwnPropertyNames(obj).forEach(key => {
36
36
  output[key] = snapshot(obj[key], max, depth + 1);
37
37
  });
38
38
 
package/src/timeout.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export async function timeout(ms: number) {
2
- return new Promise((resolve) => {
2
+ return new Promise(resolve => {
3
3
  setTimeout(resolve, ms);
4
4
  });
5
5
  }
package/tsconfig.json CHANGED
@@ -3,6 +3,7 @@
3
3
  "target": "esnext",
4
4
  "module": "nodenext",
5
5
  "moduleResolution": "nodenext",
6
+ "forceConsistentCasingInFileNames": true,
6
7
  "strict": true,
7
8
  "skipLibCheck": true,
8
9
  "allowImportingTsExtensions": true,