@beauraines/toggl-cli 2.8.8 → 2.8.10

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [2.8.10](https://github.com/beauraines/toggl-cli/compare/v2.8.9...v2.8.10) (2026-04-30)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * improve error output when offline ([#243](https://github.com/beauraines/toggl-cli/issues/243)) ([#244](https://github.com/beauraines/toggl-cli/issues/244)) ([5c8caa8](https://github.com/beauraines/toggl-cli/commit/5c8caa865dfe351258c66379906a76adff58a5db))
11
+
12
+ ### [2.8.9](https://github.com/beauraines/toggl-cli/compare/v2.8.8...v2.8.9) (2026-04-13)
13
+
5
14
  ### [2.8.8](https://github.com/beauraines/toggl-cli/compare/v2.8.7...v2.8.8) (2026-04-05)
6
15
 
7
16
 
package/client.js CHANGED
@@ -8,6 +8,39 @@ const debug = debugClient('toggl-cli-client')
8
8
 
9
9
  const QUOTA_WARNING_THRESHOLD = 5
10
10
 
11
+ const NETWORK_ERROR_CODES = new Set([
12
+ 'ENOTFOUND',
13
+ 'ECONNREFUSED',
14
+ 'ETIMEDOUT',
15
+ 'ENETUNREACH',
16
+ 'ECONNRESET',
17
+ 'EAI_AGAIN',
18
+ 'EHOSTUNREACH',
19
+ ])
20
+
21
+ const NETWORK_ERROR_MESSAGES = {
22
+ ENOTFOUND: 'Unable to resolve host — are you connected to the internet?',
23
+ ECONNREFUSED: 'Connection refused by the server.',
24
+ ETIMEDOUT: 'Connection timed out — the server may be unreachable.',
25
+ ENETUNREACH: 'Network is unreachable — check your internet connection.',
26
+ ECONNRESET: 'Connection was reset — please try again.',
27
+ EAI_AGAIN: 'DNS lookup failed — check your internet connection and try again.',
28
+ EHOSTUNREACH: 'Host is unreachable — check your internet connection.',
29
+ }
30
+
31
+ /**
32
+ * Strips sensitive credentials (API tokens, passwords) from a string
33
+ * to prevent accidental leakage in error output.
34
+ */
35
+ export function sanitizeErrorMessage (message) {
36
+ if (!message) return message
37
+ // Strip Basic auth header values
38
+ message = message.replace(/Basic\s+[A-Za-z0-9+/=]+/g, 'Basic [REDACTED]')
39
+ // Strip API tokens in URLs (username:password@host pattern)
40
+ message = message.replace(/\/\/[^@/]+@/g, '//[REDACTED]@')
41
+ return message
42
+ }
43
+
11
44
  export default async function () {
12
45
  let conf
13
46
  try {
@@ -29,10 +62,26 @@ export default async function () {
29
62
  process.exit(1)
30
63
  }
31
64
 
32
- // Wrap request method to warn when API quota is running low
65
+ // Wrap request method to handle errors and warn on quota
33
66
  const originalRequest = client.request.bind(client)
34
67
  client.request = async function (...args) {
35
- const result = await originalRequest(...args)
68
+ let result
69
+ try {
70
+ result = await originalRequest(...args)
71
+ } catch (error) {
72
+ if (NETWORK_ERROR_CODES.has(error.code)) {
73
+ const friendlyMessage = NETWORK_ERROR_MESSAGES[error.code] || 'A network error occurred.'
74
+ console.error(chalk.red(`\n✖ ${friendlyMessage}`))
75
+ debug('Full error details: %O', error)
76
+ process.exit(1)
77
+ }
78
+ // For non-network errors, sanitize and re-throw
79
+ if (error.message) {
80
+ error.message = sanitizeErrorMessage(error.message)
81
+ }
82
+ throw error
83
+ }
84
+
36
85
  if (client.quota && client.quota.remaining !== null && client.quota.remaining <= QUOTA_WARNING_THRESHOLD) {
37
86
  const minutes = client.quota.resetsIn ? Math.ceil(client.quota.resetsIn / 60) : '?'
38
87
  console.error(chalk.yellow(`\n⚠ API quota low: ${client.quota.remaining} requests remaining (resets in ~${minutes}m)`))
package/client.test.js ADDED
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from '@jest/globals'
2
+ import { sanitizeErrorMessage } from './client.js'
3
+
4
+ describe('sanitizeErrorMessage', () => {
5
+ it('should strip Basic auth header values', () => {
6
+ const input = 'Request failed: Basic YTQyOTk3YzEyZjExMTI4OTM0NGNlZDdlYWVkMmM6YXBpX3Rva2Vu'
7
+ const result = sanitizeErrorMessage(input)
8
+ expect(result).toBe('Request failed: Basic [REDACTED]')
9
+ expect(result).not.toContain('YTQyOTk3')
10
+ })
11
+
12
+ it('should strip credentials from URLs', () => {
13
+ const input = 'getaddrinfo ENOTFOUND at https://a42997c12f112821289344ced7eaed2c:api_token@api.track.toggl.com/api/v9/me'
14
+ const result = sanitizeErrorMessage(input)
15
+ expect(result).toContain('[REDACTED]@api.track.toggl.com')
16
+ expect(result).not.toContain('a42997c12f112821289344ced7eaed2c')
17
+ })
18
+
19
+ it('should handle null/undefined input', () => {
20
+ expect(sanitizeErrorMessage(null)).toBeNull()
21
+ expect(sanitizeErrorMessage(undefined)).toBeUndefined()
22
+ expect(sanitizeErrorMessage('')).toBe('')
23
+ })
24
+
25
+ it('should not modify messages without credentials', () => {
26
+ const input = 'Connection timed out'
27
+ expect(sanitizeErrorMessage(input)).toBe('Connection timed out')
28
+ })
29
+ })
package/cmds/index.mjs CHANGED
@@ -14,7 +14,9 @@ import * as today from './today.mjs'
14
14
  import * as weekly from './weekly.mjs'
15
15
  import * as createConfig from './create-config.mjs'
16
16
  import * as quota from './quota.mjs'
17
- export const commands = [
17
+ import { withErrorHandling } from '../errorHandler.js'
18
+
19
+ const rawCommands = [
18
20
  continueEntry,
19
21
  current,
20
22
  edit,
@@ -32,3 +34,8 @@ export const commands = [
32
34
  workspace,
33
35
  createConfig
34
36
  ]
37
+
38
+ export const commands = rawCommands.map(cmd => ({
39
+ ...cmd,
40
+ handler: withErrorHandling(cmd.handler)
41
+ }))
@@ -0,0 +1,26 @@
1
+ import chalk from 'chalk'
2
+ import debugClient from 'debug'
3
+ import { sanitizeErrorMessage } from './client.js'
4
+
5
+ const debug = debugClient('toggl-cli-error')
6
+
7
+ /**
8
+ * Wraps a yargs command handler with error handling that catches
9
+ * unhandled errors and displays a user-friendly message instead
10
+ * of a raw stack trace with sensitive data.
11
+ *
12
+ * @param {Function} handler - The async handler function to wrap
13
+ * @returns {Function} - The wrapped handler
14
+ */
15
+ export function withErrorHandling (handler) {
16
+ return async function (argv) {
17
+ try {
18
+ await handler(argv)
19
+ } catch (error) {
20
+ const message = sanitizeErrorMessage(error.message) || 'An unexpected error occurred.'
21
+ console.error(chalk.red(`\n✖ ${message}`))
22
+ debug('Full error details: %O', error)
23
+ process.exit(1)
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,57 @@
1
+ import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'
2
+ import { withErrorHandling } from './errorHandler.js'
3
+
4
+ describe('withErrorHandling', () => {
5
+ let mockExit
6
+ let mockConsoleError
7
+
8
+ beforeEach(() => {
9
+ mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {})
10
+ mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {})
11
+ })
12
+
13
+ afterEach(() => {
14
+ mockExit.mockRestore()
15
+ mockConsoleError.mockRestore()
16
+ })
17
+
18
+ it('should call the original handler when no error occurs', async () => {
19
+ const handler = jest.fn()
20
+ const wrapped = withErrorHandling(handler)
21
+ await wrapped({ test: true })
22
+ expect(handler).toHaveBeenCalledWith({ test: true })
23
+ expect(mockExit).not.toHaveBeenCalled()
24
+ })
25
+
26
+ it('should catch errors and display a friendly message', async () => {
27
+ const handler = jest.fn().mockRejectedValue(new Error('Something went wrong'))
28
+ const wrapped = withErrorHandling(handler)
29
+ await wrapped({})
30
+ expect(mockConsoleError).toHaveBeenCalledWith(
31
+ expect.stringContaining('Something went wrong')
32
+ )
33
+ expect(mockExit).toHaveBeenCalledWith(1)
34
+ })
35
+
36
+ it('should sanitize API tokens from error messages', async () => {
37
+ const error = new Error(
38
+ 'Request failed: Basic YTQyOTk3YzEyZjExMTI4OTM0NGNlZDdlYWVkMmM6YXBpX3Rva2Vu'
39
+ )
40
+ const handler = jest.fn().mockRejectedValue(error)
41
+ const wrapped = withErrorHandling(handler)
42
+ await wrapped({})
43
+
44
+ const errorOutput = mockConsoleError.mock.calls[0][0]
45
+ expect(errorOutput).not.toContain('YTQyOTk3')
46
+ expect(errorOutput).toContain('[REDACTED]')
47
+ expect(mockExit).toHaveBeenCalledWith(1)
48
+ })
49
+
50
+ it('should handle errors with no message', async () => {
51
+ const handler = jest.fn().mockRejectedValue(new Error())
52
+ const wrapped = withErrorHandling(handler)
53
+ await wrapped({})
54
+ expect(mockConsoleError).toHaveBeenCalled()
55
+ expect(mockExit).toHaveBeenCalledWith(1)
56
+ })
57
+ })
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@beauraines/toggl-cli",
3
- "version": "2.8.8",
3
+ "version": "2.8.10",
4
4
  "description": "CLI client for Toggl Time Tracker",
5
5
  "main": "cli.js",
6
6
  "bin": {
7
7
  "toggl": "./cli.js"
8
8
  },
9
9
  "scripts": {
10
- "test": "jest",
10
+ "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
11
11
  "lint": "eslint . ./**/*.js ./**/*.mjs",
12
12
  "lint:fix": "eslint . ./**/*.js ./**/*.mjs --fix",
13
13
  "release": "standard-version",