From 956db67343eba7019e43482b5e461b931d73c0ff Mon Sep 17 00:00:00 2001 From: Anton Yakutovich Date: Sun, 25 Oct 2026 14:12:08 +0400 Subject: [PATCH 1/2] fix(api): name the token file with ~ in TOKEN_MISSING, as doctor does doctor shortened the path with homePath (#130); the client's TOKEN_MISSING and multi-line TOKEN_INVALID messages printed it in full. README's error table now shows the short form. Closes #146 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qh9xqviBHYmzVtmQWSKqA --- CHANGELOG.md | 3 +++ README.md | 2 +- src/api/client.test.ts | 9 +++++++++ src/api/client.ts | 5 +++-- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3166362..44a1b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- `TOKEN_MISSING` from `sync` and `fetch` names the token file with `~` for the home directory (`No Oura access token at ~/.oura-token.`), the way `doctor` does; it printed the full home path. The multi-line token error names the file the same way. (#146) + ## [0.9.1] - 2026-09-15 ### Fixed diff --git a/README.md b/README.md index 32c8aad..5123472 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ A re-fetched window ends up holding exactly what the API returned for it. A samp | What you see | What to run | |---|---| | `No Oura data is available for this report yet.` | `oura-cli sync` | -| `No Oura access token at /…/.oura-token` | `oura-cli login` | +| `No Oura access token at ~/.oura-token` | `oura-cli login` | | `Oura API 401` | `oura-cli login` with a fresh PAT | | `Oura API 429` after the automatic retries | Wait a few minutes. A long `fetch hr` window is hundreds of requests. | | `db today` empty right after a sync | Normal. Oura publishes a day's summary after that night's sleep syncs from the ring. | diff --git a/src/api/client.test.ts b/src/api/client.test.ts index ba06362..731ccb3 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { OuraClient, retryDelayMs, MAX_RETRY_AFTER_MS, MAX_TOTAL_WAIT_MS, RETRY_LIMIT } from './client.js'; import { CliError } from '../lib/errors.js'; +import { homedir } from 'os'; +import { join } from 'path'; const realFetch = globalThis.fetch; @@ -78,6 +80,13 @@ describe('OuraClient', () => { if (prevToken !== undefined) process.env.OURA_TOKEN = prevToken; }); + it('names a token file under the home directory with ~, as doctor does (#146)', () => { + const dir = `oura-cli-no-such-dir-${process.pid}`; + let err: unknown; + try { new OuraClient({ tokenPath: join(homedir(), dir, '.oura-token') }); } catch (e) { err = e; } + expect((err as CliError).message).toBe(`No Oura access token at ~/${dir}/.oura-token.`); + }); + it('throws CliError with code TOKEN_MISSING and a hint to run login', () => { expect(() => new OuraClient({ tokenPath: '/nonexistent/path/.oura-token' })) .toThrow(/No Oura access token/); diff --git a/src/api/client.ts b/src/api/client.ts index 604d5a6..e94a0da 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,5 @@ import { CliError, redactSecrets } from '../lib/errors.js'; +import { homePath } from '../lib/home-path.js'; import { resolveToken } from './token.js'; import type { OuraEndpoint } from './types.js'; @@ -90,11 +91,11 @@ export class OuraClient { this.sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))); const { token, source } = resolveToken(options.token, options.tokenPath); if (!token) { - throw new CliError('TOKEN_MISSING', `No Oura access token at ${source}.`, 'Run `oura-cli login` or set OURA_TOKEN.'); + throw new CliError('TOKEN_MISSING', `No Oura access token at ${homePath(source)}.`, 'Run `oura-cli login` or set OURA_TOKEN.'); } if (/\s/.test(token)) { // A multi-line token file would otherwise reach fetch() and come back as a header error quoting the token. - throw new CliError('TOKEN_INVALID', `The token from ${source} contains whitespace or a line break; a token is a single line.`, 'Fix the file or variable, or run `oura-cli login` again.'); + throw new CliError('TOKEN_INVALID', `The token from ${homePath(source)} contains whitespace or a line break; a token is a single line.`, 'Fix the file or variable, or run `oura-cli login` again.'); } this.token = token; } From 122d2c7052e4f43c9d6be01da6fad8f142e439bc Mon Sep 17 00:00:00 2001 From: Anton Yakutovich Date: Sun, 25 Oct 2026 14:12:45 +0400 Subject: [PATCH 2/2] fix(errors): colour the error line by stderr, not stdout apply-color-mode decides chalk's level from stdout, so with stdout on a terminal and stderr redirected the error envelope carried ANSI codes into the file. formatError now asks shouldDisableColor about stderr, the stream it writes to, as the hint wrap already measures stderr's width. Closes #150 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qh9xqviBHYmzVtmQWSKqA --- CHANGELOG.md | 1 + src/lib/errors.test.ts | 12 ++++++++++++ src/lib/errors.ts | 8 ++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a1b10..a1965f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - `TOKEN_MISSING` from `sync` and `fetch` names the token file with `~` for the home directory (`No Oura access token at ~/.oura-token.`), the way `doctor` does; it printed the full home path. The multi-line token error names the file the same way. (#146) +- The `error:` line is coloured only when stderr is a terminal (and `NO_COLOR` / `--no-color` are not set, or `FORCE_COLOR` asks for it). The decision followed stdout, so `oura-cli sync 2>err.log` on a terminal wrote ANSI codes into the file. (#150) ## [0.9.1] - 2026-09-15 diff --git a/src/lib/errors.test.ts b/src/lib/errors.test.ts index 170636e..730f622 100644 --- a/src/lib/errors.test.ts +++ b/src/lib/errors.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'bun:test'; +import chalk from 'chalk'; import { CliError, exitCodeFor, formatError, redactSecrets, type ErrorEnvelope } from './errors.js'; describe('CliError', () => { @@ -115,6 +116,17 @@ describe('formatError wraps the hint at the terminal width (#130)', () => { expect(lines.slice(1).map(l => l.trim()).join(' ')).toBe(`hint: ${hint}`); }); + it('colours the error line by stderr, not stdout (#150)', () => { + const level = chalk.level; + chalk.level = 1; + try { + expect(formatError(new CliError('BAD_ARGS', 'boom'), 'table', undefined, false).text).toBe('error: boom'); + expect(formatError(new CliError('BAD_ARGS', 'boom'), 'table', undefined, true).text).toBe('\u001b[31merror: boom\u001b[39m'); + } finally { + chalk.level = level; + } + }); + it('keeps the hint on one line when stderr is a pipe', () => { const lines = formatError(new CliError('BAD_ARGS', 'boom', hint), 'table', undefined).text.split('\n'); expect(lines).toHaveLength(2); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 4267e64..c64ed7d 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import { shouldDisableColor } from './color-mode.js'; import { screenWidth } from './terminal.js'; import { wrap } from './wrap.js'; @@ -35,7 +36,10 @@ export function redactSecrets(s: string): string { .replace(/"token"\s*:\s*"[^"]{8,}"/g, '"token":"[REDACTED]"'); } -export function formatError(err: unknown, format: 'json' | 'table', width = screenWidth(process.stderr)): ErrorEnvelope { +export function formatError( + err: unknown, format: 'json' | 'table', width = screenWidth(process.stderr), + colour = !shouldDisableColor(process.argv, process.env, process.stderr.isTTY === true), +): ErrorEnvelope { const code = err instanceof CliError ? err.code : 'UNKNOWN'; // Redact here as well as at the API boundary: an unexpected error (e.g. from fetch) may quote a header. const message = redactSecrets(err instanceof Error ? err.message : String(err)); @@ -48,7 +52,7 @@ export function formatError(err: unknown, format: 'json' | 'table', width = scre }; } - const head = chalk.red(`error: ${message}`); + const head = colour ? chalk.red(`error: ${message}`) : `error: ${message}`; // A hint is prose, often a full sentence with a command in it: on a terminal it breaks between // words at the screen width and continues under its own text (#130). return { kind: 'text', text: hint ? [head, ...wrap(hint, width, ' '.repeat(' hint: '.length), ' hint: ')].join('\n') : head };