Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `--<flag> 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 <directory>` (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 `<dir>/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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
9 changes: 9 additions & 0 deletions src/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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/);
Expand Down
5 changes: 3 additions & 2 deletions src/api/client.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down
12 changes: 12 additions & 0 deletions src/lib/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
Comment on lines +123 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stderr choice stays untested

Both calls pass colour explicitly, so neither checks the stderr-based default that fixes #150. Changing process.stderr.isTTY back to process.stdout.isTTY would leave this test passing. Add a test with different stdout and stderr TTY states, and omit the fourth argument.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

} 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);
Expand Down
8 changes: 6 additions & 2 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import chalk from 'chalk';
import { shouldDisableColor } from './color-mode.js';
import { screenWidth } from './terminal.js';
import { wrap } from './wrap.js';

Expand Down Expand Up @@ -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));
Expand All @@ -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 };
Expand Down
Loading