@beauraines/toggl-cli 2.8.9 → 2.8.11
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 +14 -0
- package/client.js +51 -2
- package/client.test.js +29 -0
- package/cmds/index.mjs +8 -1
- package/errorHandler.js +26 -0
- package/errorHandler.test.js +57 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.11](https://github.com/beauraines/toggl-cli/compare/v2.8.10...v2.8.11) (2026-05-02)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **deps:** bump dotenv from 17.3.1 to 17.4.2 ([#246](https://github.com/beauraines/toggl-cli/issues/246)) ([966b073](https://github.com/beauraines/toggl-cli/commit/966b073985aed7c580a2ed2997d22cb9b789c51f))
|
|
11
|
+
|
|
12
|
+
### [2.8.10](https://github.com/beauraines/toggl-cli/compare/v2.8.9...v2.8.10) (2026-04-30)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* 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))
|
|
18
|
+
|
|
5
19
|
### [2.8.9](https://github.com/beauraines/toggl-cli/compare/v2.8.8...v2.8.9) (2026-04-13)
|
|
6
20
|
|
|
7
21
|
### [2.8.8](https://github.com/beauraines/toggl-cli/compare/v2.8.7...v2.8.8) (2026-04-05)
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}))
|
package/errorHandler.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "2.8.11",
|
|
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",
|