diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7e9f3..92a3944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A flag given without a value is refused the same way everywhere, as `--format`, `--db` and `doctor --token` already were: `--tz`, `report --period`, `--token` on `fetch`, `sync` and the `db` commands, and `login --token ""` now say `-- has no value` and name what leaving the flag out falls back to. `--tz` said `Unknown timezone ""`, `--period` said `got ""`, `--token` was dropped for the token file (a `TOKEN_MISSING` naming the file, or silently accepted by `db today`), and `login` fell through to the interactive prompt. (#148) - `login --path ` (or `OURA_TOKEN_PATH` naming one) is `BAD_ARGS` saying the path is a directory, with a file path inside it as the example; it printed a raw `EISDIR: illegal operation on a directory` with no hint. (#149) - `--db` or `OURA_DB_PATH` naming a directory is a `DB_ERROR` saying so, with `/oura.db` as the example, and `doctor` shows the same fix; SQLite's `unable to open database file` sent the user to check permissions that were fine. (#151) +- `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/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; } 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 };