whoop-up 1.0.1
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/.env.example +3 -0
- package/CLAUDE.md +277 -0
- package/README.md +278 -0
- package/SKILL.md +235 -0
- package/dist/api/client.d.ts +24 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +149 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/endpoints.d.ts +18 -0
- package/dist/api/endpoints.d.ts.map +1 -0
- package/dist/api/endpoints.js +19 -0
- package/dist/api/endpoints.js.map +1 -0
- package/dist/auth/oauth.d.ts +5 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/auth/oauth.js +140 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/tokens.d.ts +12 -0
- package/dist/auth/tokens.d.ts.map +1 -0
- package/dist/auth/tokens.js +102 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/charts/generator.d.ts +8 -0
- package/dist/charts/generator.d.ts.map +1 -0
- package/dist/charts/generator.js +445 -0
- package/dist/charts/generator.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +370 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/types/whoop.d.ts +156 -0
- package/dist/types/whoop.d.ts.map +1 -0
- package/dist/types/whoop.js +3 -0
- package/dist/types/whoop.js.map +1 -0
- package/dist/utils/analysis.d.ts +30 -0
- package/dist/utils/analysis.d.ts.map +1 -0
- package/dist/utils/analysis.js +246 -0
- package/dist/utils/analysis.js.map +1 -0
- package/dist/utils/constants.d.ts +16 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/constants.js +25 -0
- package/dist/utils/constants.js.map +1 -0
- package/dist/utils/date.d.ts +14 -0
- package/dist/utils/date.d.ts.map +1 -0
- package/dist/utils/date.js +48 -0
- package/dist/utils/date.js.map +1 -0
- package/dist/utils/errors.d.ts +14 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +36 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/format.d.ts +25 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/format.js +262 -0
- package/dist/utils/format.js.map +1 -0
- package/docs/COMMANDS.md +435 -0
- package/package.json +54 -0
- package/references/health_analysis.md +212 -0
- package/src/api/client.ts +207 -0
- package/src/api/endpoints.ts +20 -0
- package/src/auth/oauth.ts +171 -0
- package/src/auth/tokens.ts +120 -0
- package/src/charts/generator.ts +493 -0
- package/src/cli.ts +433 -0
- package/src/index.ts +8 -0
- package/src/types/whoop.ts +192 -0
- package/src/utils/analysis.ts +321 -0
- package/src/utils/constants.ts +32 -0
- package/src/utils/date.ts +58 -0
- package/src/utils/errors.ts +38 -0
- package/src/utils/format.ts +323 -0
- package/tests/cli/cli.test.ts +49 -0
- package/tests/utils/analysis.test.ts +152 -0
- package/tests/utils/date.test.ts +69 -0
- package/tests/utils/errors.test.ts +33 -0
- package/tests/utils/format.test.ts +229 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { getValidTokens } from '../auth/tokens.js';
|
|
2
|
+
import { BASE_URL, ENDPOINTS, byId } from './endpoints.js';
|
|
3
|
+
import { WhoopError, ExitCode } from '../utils/errors.js';
|
|
4
|
+
import type {
|
|
5
|
+
WhoopProfile,
|
|
6
|
+
WhoopBody,
|
|
7
|
+
WhoopSleep,
|
|
8
|
+
WhoopRecovery,
|
|
9
|
+
WhoopWorkout,
|
|
10
|
+
WhoopCycle,
|
|
11
|
+
ApiResponse,
|
|
12
|
+
QueryParams,
|
|
13
|
+
CombinedOutput,
|
|
14
|
+
DataType,
|
|
15
|
+
} from '../types/whoop.js';
|
|
16
|
+
import { getDaysRange, getDateRange, nowISO } from '../utils/date.js';
|
|
17
|
+
|
|
18
|
+
const USER_AGENT = 'whoop-up/1.0';
|
|
19
|
+
const RETRY_CODES = new Set([429, 500, 502, 503, 504]);
|
|
20
|
+
const MAX_RETRIES = 3;
|
|
21
|
+
const MAX_PAGES = 50;
|
|
22
|
+
|
|
23
|
+
async function request<T>(endpoint: string, params?: QueryParams): Promise<T> {
|
|
24
|
+
const tokens = await getValidTokens();
|
|
25
|
+
|
|
26
|
+
const url = new URL(BASE_URL + endpoint);
|
|
27
|
+
if (params?.start) url.searchParams.set('start', params.start);
|
|
28
|
+
if (params?.end) url.searchParams.set('end', params.end);
|
|
29
|
+
if (params?.limit) url.searchParams.set('limit', String(params.limit));
|
|
30
|
+
if (params?.nextToken) url.searchParams.set('nextToken', params.nextToken);
|
|
31
|
+
|
|
32
|
+
let backoff = 1000;
|
|
33
|
+
|
|
34
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
35
|
+
const response = await fetch(url.toString(), {
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
'User-Agent': USER_AGENT,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (response.ok) {
|
|
44
|
+
return response.json() as Promise<T>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (RETRY_CODES.has(response.status) && attempt < MAX_RETRIES) {
|
|
48
|
+
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
49
|
+
backoff *= 2;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (response.status === 401) {
|
|
54
|
+
throw new WhoopError('Authentication failed', ExitCode.AUTH_ERROR, 401);
|
|
55
|
+
}
|
|
56
|
+
if (response.status === 429) {
|
|
57
|
+
throw new WhoopError('Rate limit exceeded — try again later', ExitCode.RATE_LIMIT, 429);
|
|
58
|
+
}
|
|
59
|
+
throw new WhoopError(`API request failed`, ExitCode.GENERAL_ERROR, response.status);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new WhoopError('API request failed after retries', ExitCode.GENERAL_ERROR);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function requestDelete(endpoint: string): Promise<void> {
|
|
66
|
+
const tokens = await getValidTokens();
|
|
67
|
+
|
|
68
|
+
const response = await fetch(BASE_URL + endpoint, {
|
|
69
|
+
method: 'DELETE',
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
72
|
+
'User-Agent': USER_AGENT,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!response.ok && response.status !== 204) {
|
|
77
|
+
throw new WhoopError(`DELETE request failed`, ExitCode.GENERAL_ERROR, response.status);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fetchAll<T>(
|
|
82
|
+
endpoint: string,
|
|
83
|
+
params: QueryParams,
|
|
84
|
+
fetchAllPages: boolean
|
|
85
|
+
): Promise<T[]> {
|
|
86
|
+
const results: T[] = [];
|
|
87
|
+
let nextToken: string | undefined;
|
|
88
|
+
let pages = 0;
|
|
89
|
+
|
|
90
|
+
do {
|
|
91
|
+
if (pages >= MAX_PAGES) {
|
|
92
|
+
console.error(`Warning: pagination cap (${MAX_PAGES} pages) reached for ${endpoint}`);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
const response = await request<ApiResponse<T>>(endpoint, { ...params, nextToken });
|
|
96
|
+
results.push(...response.records);
|
|
97
|
+
nextToken = fetchAllPages ? response.next_token : undefined;
|
|
98
|
+
pages++;
|
|
99
|
+
} while (nextToken);
|
|
100
|
+
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Collection endpoints ─────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export async function getProfile(): Promise<WhoopProfile> {
|
|
107
|
+
return request<WhoopProfile>(ENDPOINTS.profile);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function getBody(): Promise<WhoopBody> {
|
|
111
|
+
return request<WhoopBody>(ENDPOINTS.body);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function getSleep(params: QueryParams = {}, all = true): Promise<WhoopSleep[]> {
|
|
115
|
+
return fetchAll<WhoopSleep>(ENDPOINTS.sleep, { limit: 25, ...params }, all);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function getRecovery(params: QueryParams = {}, all = true): Promise<WhoopRecovery[]> {
|
|
119
|
+
return fetchAll<WhoopRecovery>(ENDPOINTS.recovery, { limit: 25, ...params }, all);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function getWorkout(params: QueryParams = {}, all = true): Promise<WhoopWorkout[]> {
|
|
123
|
+
return fetchAll<WhoopWorkout>(ENDPOINTS.workout, { limit: 25, ...params }, all);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function getCycle(params: QueryParams = {}, all = true): Promise<WhoopCycle[]> {
|
|
127
|
+
return fetchAll<WhoopCycle>(ENDPOINTS.cycle, { limit: 25, ...params }, all);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── By-ID endpoints ──────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export async function getSleepById(id: string): Promise<WhoopSleep> {
|
|
133
|
+
return request<WhoopSleep>(byId.sleep(id));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function getWorkoutById(id: string): Promise<WhoopWorkout> {
|
|
137
|
+
return request<WhoopWorkout>(byId.workout(id));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function getCycleById(id: number): Promise<WhoopCycle> {
|
|
141
|
+
return request<WhoopCycle>(byId.cycle(id));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Cycle sub-resource endpoints ─────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export async function getSleepForCycle(cycleId: number): Promise<WhoopSleep> {
|
|
147
|
+
return request<WhoopSleep>(byId.cycleSleep(cycleId));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function getRecoveryForCycle(cycleId: number): Promise<WhoopRecovery> {
|
|
151
|
+
return request<WhoopRecovery>(byId.cycleRecovery(cycleId));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Revoke access ────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
export async function revokeAccess(): Promise<void> {
|
|
157
|
+
return requestDelete(ENDPOINTS.revokeAccess);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Composite helpers ────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
export async function fetchData(
|
|
163
|
+
types: DataType[],
|
|
164
|
+
rangeParams: QueryParams,
|
|
165
|
+
options: { limit?: number; all?: boolean } = {}
|
|
166
|
+
): Promise<CombinedOutput> {
|
|
167
|
+
const params: QueryParams = { ...rangeParams, limit: options.limit ?? 25 };
|
|
168
|
+
const fetchAllPages = options.all ?? true;
|
|
169
|
+
|
|
170
|
+
const output: CombinedOutput = {
|
|
171
|
+
date: new Date().toISOString().split('T')[0],
|
|
172
|
+
fetched_at: nowISO(),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const fetchers: Record<DataType, () => Promise<void>> = {
|
|
176
|
+
profile: async () => { output.profile = await getProfile(); },
|
|
177
|
+
body: async () => { output.body = await getBody(); },
|
|
178
|
+
sleep: async () => { output.sleep = await getSleep(params, fetchAllPages); },
|
|
179
|
+
recovery: async () => { output.recovery = await getRecovery(params, fetchAllPages); },
|
|
180
|
+
workout: async () => { output.workout = await getWorkout(params, fetchAllPages); },
|
|
181
|
+
cycle: async () => { output.cycle = await getCycle(params, fetchAllPages); },
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
await Promise.all(types.map((type) => fetchers[type]()));
|
|
185
|
+
|
|
186
|
+
return output;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function buildRangeParams(options: {
|
|
190
|
+
days?: number;
|
|
191
|
+
start?: string;
|
|
192
|
+
end?: string;
|
|
193
|
+
date?: string;
|
|
194
|
+
}): QueryParams {
|
|
195
|
+
if (options.start || options.end) {
|
|
196
|
+
return {
|
|
197
|
+
start: options.start ? options.start + 'T00:00:00.000Z' : undefined,
|
|
198
|
+
end: options.end ? options.end + 'T23:59:59.999Z' : undefined,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (options.date) {
|
|
202
|
+
// Use WHOOP day boundaries (4am–4am) for a single date
|
|
203
|
+
return getDateRange(options.date);
|
|
204
|
+
}
|
|
205
|
+
// Default: last N days
|
|
206
|
+
return getDaysRange(options.days ?? 7);
|
|
207
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const BASE_URL = 'https://api.prod.whoop.com/developer/v2';
|
|
2
|
+
|
|
3
|
+
export const ENDPOINTS = {
|
|
4
|
+
profile: '/user/profile/basic',
|
|
5
|
+
body: '/user/measurement/body',
|
|
6
|
+
workout: '/activity/workout',
|
|
7
|
+
sleep: '/activity/sleep',
|
|
8
|
+
recovery: '/recovery',
|
|
9
|
+
cycle: '/cycle',
|
|
10
|
+
revokeAccess: '/user/access',
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
// By-ID endpoints (parametric)
|
|
14
|
+
export const byId = {
|
|
15
|
+
sleep: (id: string) => `/activity/sleep/${id}`,
|
|
16
|
+
workout: (id: string) => `/activity/workout/${id}`,
|
|
17
|
+
cycle: (id: number) => `/cycle/${id}`,
|
|
18
|
+
cycleSleep: (cycleId: number) => `/cycle/${cycleId}/sleep`,
|
|
19
|
+
cycleRecovery: (cycleId: number) => `/cycle/${cycleId}/recovery`,
|
|
20
|
+
} as const;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import { saveTokens, clearTokens, getTokenStatus, getValidTokens, isTokenExpired, loadTokens } from './tokens.js';
|
|
5
|
+
import { WhoopError, ExitCode } from '../utils/errors.js';
|
|
6
|
+
import type { OAuthTokenResponse } from '../types/whoop.js';
|
|
7
|
+
|
|
8
|
+
const WHOOP_AUTH_URL = 'https://api.prod.whoop.com/oauth/oauth2/auth';
|
|
9
|
+
const WHOOP_TOKEN_URL = 'https://api.prod.whoop.com/oauth/oauth2/token';
|
|
10
|
+
const WHOOP_REVOKE_URL = 'https://api.prod.whoop.com/developer/v2/user/access';
|
|
11
|
+
const SCOPES = 'read:profile read:body_measurement read:workout read:recovery read:sleep read:cycles offline';
|
|
12
|
+
|
|
13
|
+
function getCredentials(): { clientId: string; clientSecret: string; redirectUri: string } {
|
|
14
|
+
const clientId = process.env.WHOOP_CLIENT_ID;
|
|
15
|
+
const clientSecret = process.env.WHOOP_CLIENT_SECRET;
|
|
16
|
+
const redirectUri = process.env.WHOOP_REDIRECT_URI;
|
|
17
|
+
|
|
18
|
+
if (!clientId || !clientSecret || !redirectUri) {
|
|
19
|
+
throw new WhoopError(
|
|
20
|
+
'Missing WHOOP_CLIENT_ID, WHOOP_CLIENT_SECRET, or WHOOP_REDIRECT_URI in environment',
|
|
21
|
+
ExitCode.AUTH_ERROR
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { clientId, clientSecret, redirectUri };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function prompt(question: string): Promise<string> {
|
|
29
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
rl.question(question, (answer) => {
|
|
32
|
+
rl.close();
|
|
33
|
+
resolve(answer.trim());
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function login(): Promise<void> {
|
|
39
|
+
const { clientId, clientSecret, redirectUri } = getCredentials();
|
|
40
|
+
const state = randomBytes(16).toString('hex');
|
|
41
|
+
|
|
42
|
+
const authUrl = new URL(WHOOP_AUTH_URL);
|
|
43
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
44
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
45
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
46
|
+
authUrl.searchParams.set('scope', SCOPES);
|
|
47
|
+
authUrl.searchParams.set('state', state);
|
|
48
|
+
|
|
49
|
+
console.log('Opening browser for authorization...');
|
|
50
|
+
console.log('\nIf browser does not open, visit this URL:\n');
|
|
51
|
+
console.log(authUrl.toString());
|
|
52
|
+
console.log('');
|
|
53
|
+
|
|
54
|
+
await open(authUrl.toString()).catch(() => {});
|
|
55
|
+
|
|
56
|
+
const callbackUrl = await prompt('Paste the callback URL here: ');
|
|
57
|
+
|
|
58
|
+
const url = new URL(callbackUrl);
|
|
59
|
+
const code = url.searchParams.get('code');
|
|
60
|
+
const returnedState = url.searchParams.get('state');
|
|
61
|
+
|
|
62
|
+
if (!code) {
|
|
63
|
+
throw new WhoopError('No authorization code in callback URL', ExitCode.AUTH_ERROR);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (returnedState !== state) {
|
|
67
|
+
throw new WhoopError('OAuth state mismatch', ExitCode.AUTH_ERROR);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tokenResponse = await fetch(WHOOP_TOKEN_URL, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
73
|
+
body: new URLSearchParams({
|
|
74
|
+
grant_type: 'authorization_code',
|
|
75
|
+
code,
|
|
76
|
+
redirect_uri: redirectUri,
|
|
77
|
+
client_id: clientId,
|
|
78
|
+
client_secret: clientSecret,
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!tokenResponse.ok) {
|
|
83
|
+
const text = await tokenResponse.text();
|
|
84
|
+
throw new WhoopError(`Token exchange failed: ${text}`, ExitCode.AUTH_ERROR, tokenResponse.status);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tokens = (await tokenResponse.json()) as OAuthTokenResponse;
|
|
88
|
+
saveTokens(tokens);
|
|
89
|
+
console.log('Authentication successful');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function logout(): Promise<void> {
|
|
93
|
+
const tokens = loadTokens();
|
|
94
|
+
|
|
95
|
+
if (tokens) {
|
|
96
|
+
// Revoke on WHOOP's server first, then clear locally
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(WHOOP_REVOKE_URL, {
|
|
99
|
+
method: 'DELETE',
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
102
|
+
'User-Agent': 'whoop-up/1.0',
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
if (response.ok || response.status === 204) {
|
|
106
|
+
console.log('Access revoked on WHOOP server');
|
|
107
|
+
} else {
|
|
108
|
+
// Token may already be expired — still clear locally
|
|
109
|
+
console.log(`Note: server revoke returned ${response.status}, clearing local tokens anyway`);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
console.log('Note: could not reach WHOOP server, clearing local tokens anyway');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
clearTokens();
|
|
117
|
+
console.log('Logged out');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function status(): void {
|
|
121
|
+
const tokenStatus = getTokenStatus();
|
|
122
|
+
const tokens = loadTokens();
|
|
123
|
+
|
|
124
|
+
if (!tokenStatus.authenticated) {
|
|
125
|
+
console.log(JSON.stringify({ authenticated: false, message: 'Not logged in. Run: whoop auth login' }, null, 2));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const now = Math.floor(Date.now() / 1000);
|
|
130
|
+
const expiresIn = tokenStatus.expires_at! - now;
|
|
131
|
+
const needsRefresh = isTokenExpired(tokens!);
|
|
132
|
+
|
|
133
|
+
console.log(JSON.stringify({
|
|
134
|
+
authenticated: true,
|
|
135
|
+
expires_at: tokenStatus.expires_at,
|
|
136
|
+
expires_in_seconds: expiresIn,
|
|
137
|
+
expires_in_human: expiresIn > 0 ? `${Math.floor(expiresIn / 60)} minutes` : 'EXPIRED',
|
|
138
|
+
needs_refresh: needsRefresh,
|
|
139
|
+
}, null, 2));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function refresh(): Promise<void> {
|
|
143
|
+
const tokens = loadTokens();
|
|
144
|
+
|
|
145
|
+
if (!tokens) {
|
|
146
|
+
throw new WhoopError('Not authenticated. Run: whoop auth login', ExitCode.AUTH_ERROR);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const newTokens = await getValidTokens();
|
|
151
|
+
|
|
152
|
+
const now = Math.floor(Date.now() / 1000);
|
|
153
|
+
const expiresIn = newTokens.expires_at - now;
|
|
154
|
+
|
|
155
|
+
console.log(JSON.stringify({
|
|
156
|
+
success: true,
|
|
157
|
+
message: 'Token refreshed successfully',
|
|
158
|
+
expires_at: newTokens.expires_at,
|
|
159
|
+
expires_in_seconds: expiresIn,
|
|
160
|
+
expires_in_human: `${Math.floor(expiresIn / 60)} minutes`,
|
|
161
|
+
}, null, 2));
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error instanceof WhoopError && error.message.includes('refresh')) {
|
|
164
|
+
throw new WhoopError(
|
|
165
|
+
'Refresh token expired. Please re-authenticate with: whoop auth login',
|
|
166
|
+
ExitCode.AUTH_ERROR
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { TokenData, OAuthTokenResponse } from '../types/whoop.js';
|
|
5
|
+
import { WhoopError, ExitCode } from '../utils/errors.js';
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = join(homedir(), '.whoop-up');
|
|
8
|
+
const TOKEN_FILE = join(CONFIG_DIR, 'tokens.json');
|
|
9
|
+
|
|
10
|
+
// Refresh tokens 15 minutes before expiry to avoid race conditions
|
|
11
|
+
const REFRESH_BUFFER_SECONDS = 900;
|
|
12
|
+
|
|
13
|
+
function ensureConfigDir(): void {
|
|
14
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
15
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function saveTokens(response: OAuthTokenResponse): void {
|
|
20
|
+
ensureConfigDir();
|
|
21
|
+
|
|
22
|
+
const data: TokenData = {
|
|
23
|
+
access_token: response.access_token,
|
|
24
|
+
refresh_token: response.refresh_token,
|
|
25
|
+
expires_at: Math.floor(Date.now() / 1000) + response.expires_in,
|
|
26
|
+
token_type: response.token_type,
|
|
27
|
+
scope: response.scope,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2));
|
|
31
|
+
chmodSync(TOKEN_FILE, 0o600);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadTokens(): TokenData | null {
|
|
35
|
+
if (!existsSync(TOKEN_FILE)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const content = readFileSync(TOKEN_FILE, 'utf-8');
|
|
41
|
+
return JSON.parse(content) as TokenData;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clearTokens(): void {
|
|
48
|
+
if (existsSync(TOKEN_FILE)) {
|
|
49
|
+
writeFileSync(TOKEN_FILE, '');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isTokenExpired(tokens: TokenData): boolean {
|
|
54
|
+
const now = Math.floor(Date.now() / 1000);
|
|
55
|
+
return now >= tokens.expires_at - REFRESH_BUFFER_SECONDS;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function refreshAccessToken(tokens: TokenData): Promise<TokenData> {
|
|
59
|
+
const clientId = process.env.WHOOP_CLIENT_ID;
|
|
60
|
+
const clientSecret = process.env.WHOOP_CLIENT_SECRET;
|
|
61
|
+
|
|
62
|
+
if (!clientId || !clientSecret) {
|
|
63
|
+
throw new WhoopError('Missing WHOOP_CLIENT_ID or WHOOP_CLIENT_SECRET', ExitCode.AUTH_ERROR);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const response = await fetch('https://api.prod.whoop.com/oauth/oauth2/token', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
70
|
+
},
|
|
71
|
+
body: new URLSearchParams({
|
|
72
|
+
grant_type: 'refresh_token',
|
|
73
|
+
refresh_token: tokens.refresh_token,
|
|
74
|
+
client_id: clientId,
|
|
75
|
+
client_secret: clientSecret,
|
|
76
|
+
scope: 'offline',
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const errorBody = await response.text();
|
|
82
|
+
let errorMsg = `Token refresh failed (${response.status})`;
|
|
83
|
+
try {
|
|
84
|
+
const errorJson = JSON.parse(errorBody);
|
|
85
|
+
errorMsg = errorJson.error_description || errorJson.error || errorMsg;
|
|
86
|
+
} catch {
|
|
87
|
+
// Use default error message
|
|
88
|
+
}
|
|
89
|
+
throw new WhoopError(errorMsg, ExitCode.AUTH_ERROR, response.status);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const data = (await response.json()) as OAuthTokenResponse;
|
|
93
|
+
saveTokens(data);
|
|
94
|
+
return loadTokens()!;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function getValidTokens(): Promise<TokenData> {
|
|
98
|
+
let tokens = loadTokens();
|
|
99
|
+
|
|
100
|
+
if (!tokens) {
|
|
101
|
+
throw new WhoopError('Not authenticated. Run: whoop auth login', ExitCode.AUTH_ERROR);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isTokenExpired(tokens)) {
|
|
105
|
+
tokens = await refreshAccessToken(tokens);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return tokens;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getTokenStatus(): { authenticated: boolean; expires_at?: number } {
|
|
112
|
+
const tokens = loadTokens();
|
|
113
|
+
if (!tokens) {
|
|
114
|
+
return { authenticated: false };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
authenticated: true,
|
|
118
|
+
expires_at: tokens.expires_at,
|
|
119
|
+
};
|
|
120
|
+
}
|