Skip to content
Open
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
WHOOP_CLIENT_ID=
WHOOP_CLIENT_SECRET=
WHOOP_REDIRECT_URI=
# Optional: custom path for OAuth token storage (default: ~/.whoop-cli/tokens.json)
WHOOP_TOKEN_FILE=
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Before using, you need to configure WHOOP API credentials:
export WHOOP_CLIENT_ID=your_client_id
export WHOOP_CLIENT_SECRET=your_client_secret
export WHOOP_REDIRECT_URI=https://your-redirect-uri.com/callback
# Optional: override token storage path for multi-account setups
export WHOOP_TOKEN_FILE=~/.whoop-cli/tokens-personal.json
```

Or create a `.env` file in your working directory.
Expand All @@ -45,7 +47,9 @@ Or create a `.env` file in your working directory.
whoopskill auth login
```

Tokens are stored in `~/.whoop-cli/tokens.json` and auto-refresh when expired.
Tokens are stored in `~/.whoop-cli/tokens.json` by default and auto-refresh when expired.

Set `WHOOP_TOKEN_FILE` to override token location (useful for multiple users/accounts on one machine).

## Usage

Expand Down Expand Up @@ -96,7 +100,7 @@ Important:
- For automation, you must call `whoopskill auth refresh` periodically.

Recommended pattern:
- Run `whoopskill auth login` once interactively (creates `~/.whoop-cli/tokens.json`).
- Run `whoopskill auth login` once interactively (creates the default `~/.whoop-cli/tokens.json`, or your `WHOOP_TOKEN_FILE` path if set).
- Run a small periodic monitor that calls `whoopskill auth refresh` and performs a lightweight fetch.

An example monitor script + systemd timer/cron examples are included here:
Expand Down
39 changes: 28 additions & 11 deletions src/auth/tokens.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import type { TokenData, OAuthTokenResponse } from '../types/whoop.js';
import { WhoopError, ExitCode } from '../utils/errors.js';

const CONFIG_DIR = join(homedir(), '.whoop-cli');
const TOKEN_FILE = join(CONFIG_DIR, 'tokens.json');
const DEFAULT_TOKEN_FILE = join(homedir(), '.whoop-cli', 'tokens.json');

function getTokenFilePath(): string {
const envPath = process.env.WHOOP_TOKEN_FILE?.trim();
if (!envPath) {
return DEFAULT_TOKEN_FILE;
}

// Resolve relative paths against current working directory
return resolve(envPath);
}

function getConfigDir(): string {
return dirname(getTokenFilePath());
}

// Refresh tokens 15 minutes before expiry to avoid race conditions
const REFRESH_BUFFER_SECONDS = 900;

function ensureConfigDir(): void {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
const configDir = getConfigDir();
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
}

export function saveTokens(response: OAuthTokenResponse): void {
ensureConfigDir();
const tokenFile = getTokenFilePath();

const data: TokenData = {
access_token: response.access_token,
Expand All @@ -27,26 +42,28 @@ export function saveTokens(response: OAuthTokenResponse): void {
scope: response.scope,
};

writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2));
chmodSync(TOKEN_FILE, 0o600);
writeFileSync(tokenFile, JSON.stringify(data, null, 2));
chmodSync(tokenFile, 0o600);
}

export function loadTokens(): TokenData | null {
if (!existsSync(TOKEN_FILE)) {
const tokenFile = getTokenFilePath();
if (!existsSync(tokenFile)) {
return null;
}

try {
const content = readFileSync(TOKEN_FILE, 'utf-8');
const content = readFileSync(tokenFile, 'utf-8');
return JSON.parse(content) as TokenData;
} catch {
return null;
}
}

export function clearTokens(): void {
if (existsSync(TOKEN_FILE)) {
writeFileSync(TOKEN_FILE, '');
const tokenFile = getTokenFilePath();
if (existsSync(tokenFile)) {
writeFileSync(tokenFile, '');
}
}

Expand Down