@athletelog/cli 0.1.0-beta.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.
Files changed (2) hide show
  1. package/dist/index.js +450 -0
  2. package/package.json +23 -0
package/dist/index.js ADDED
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { parseArgs } from "util";
5
+
6
+ // src/config.ts
7
+ import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync, rmSync } from "fs";
8
+ import { homedir } from "os";
9
+ import { join } from "path";
10
+ var ENV_BASE_URLS = {
11
+ prod: "https://api.athletelog.ai",
12
+ staging: "https://api-staging.athletelog.ai",
13
+ dev: ""
14
+ // dev has no fixed host; it comes from a literal --url / ATHLETELOG_API_URL / legacy convexUrl
15
+ };
16
+ var CONFIG_DIR = join(homedir(), ".athletelog");
17
+ var CONFIG_PATH = join(CONFIG_DIR, "config.json");
18
+ function readStored() {
19
+ if (!existsSync(CONFIG_PATH)) return {};
20
+ try {
21
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+ function toRequestBase(raw) {
27
+ return raw.includes(".convex.cloud") ? raw.replace(".convex.cloud", ".convex.site") : raw;
28
+ }
29
+ function resolveBaseUrl(o, stored) {
30
+ const literal = o.url ?? process.env.ATHLETELOG_API_URL ?? stored.url ?? stored.convexUrl;
31
+ if (literal) {
32
+ const env = literal.includes(".convex.") ? "dev" : "unknown";
33
+ return { baseUrl: toRequestBase(literal), env };
34
+ }
35
+ const named = o.env ?? process.env.ATHLETELOG_ENV ?? stored.env ?? "prod";
36
+ const base = ENV_BASE_URLS[named] ?? ENV_BASE_URLS.prod;
37
+ return { baseUrl: toRequestBase(base), env: named };
38
+ }
39
+ function loadConfig(overrides = {}) {
40
+ const stored = readStored();
41
+ const apiKey = overrides.apiKey ?? process.env.ATHLETELOG_API_KEY ?? stored.apiKey;
42
+ if (!apiKey) {
43
+ throw new Error(
44
+ `No API key. Run "athletelog login", or pass --key / set ATHLETELOG_API_KEY.`
45
+ );
46
+ }
47
+ const { baseUrl, env } = resolveBaseUrl(overrides, stored);
48
+ if (!baseUrl) {
49
+ throw new Error(
50
+ `No URL resolved for dev. Pass --url <https://\u2026convex.site> or set ATHLETELOG_API_URL.`
51
+ );
52
+ }
53
+ warnOnPrefixMismatch(apiKey, env);
54
+ return { apiKey, baseUrl, env };
55
+ }
56
+ function parseKeyEnv(key) {
57
+ const m = /^alog_(dev|staging|prod)_/.exec(key);
58
+ return m?.[1] ?? "unknown";
59
+ }
60
+ function warnOnPrefixMismatch(key, env) {
61
+ const keyEnv = parseKeyEnv(key);
62
+ if (keyEnv === "unknown") {
63
+ process.stderr.write(
64
+ `Warning: API key prefix is not a recognized alog_<env>_ form; cannot verify it matches "${env}".
65
+ `
66
+ );
67
+ return;
68
+ }
69
+ if (env !== "unknown" && keyEnv !== env) {
70
+ process.stderr.write(
71
+ `Warning: API key is for "${keyEnv}" but the target is "${env}". This will likely fail to authenticate.
72
+ `
73
+ );
74
+ }
75
+ }
76
+ function writeConfig(config) {
77
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
78
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
79
+ chmodSync(CONFIG_PATH, 384);
80
+ }
81
+ function clearConfig() {
82
+ if (existsSync(CONFIG_PATH)) rmSync(CONFIG_PATH);
83
+ }
84
+
85
+ // src/version.ts
86
+ import { readFileSync as readFileSync2 } from "fs";
87
+ import { fileURLToPath } from "url";
88
+ import { dirname, join as join2 } from "path";
89
+ var cached;
90
+ function getVersion() {
91
+ if (cached) return cached;
92
+ const here = dirname(fileURLToPath(import.meta.url));
93
+ const pkgPath = join2(here, "..", "package.json");
94
+ try {
95
+ cached = JSON.parse(readFileSync2(pkgPath, "utf8")).version ?? "0.0.0";
96
+ } catch {
97
+ cached = "0.0.0";
98
+ }
99
+ return cached;
100
+ }
101
+
102
+ // src/auth-client.ts
103
+ var REFRESH_MARGIN_MS = 5 * 60 * 1e3;
104
+ async function exchangeKeyForToken(config) {
105
+ const res = await fetch(`${config.baseUrl}/athletelog/auth/token`, {
106
+ method: "POST",
107
+ headers: {
108
+ Authorization: `Bearer ${config.apiKey}`,
109
+ // never logged
110
+ "User-Agent": `athletelog-cli/${getVersion()}`
111
+ }
112
+ });
113
+ if (!res.ok) {
114
+ const body = await res.text();
115
+ let detail = body;
116
+ try {
117
+ const parsed = JSON.parse(body);
118
+ detail = parsed?.error?.message ?? parsed?.error?.code ?? body;
119
+ } catch {
120
+ }
121
+ throw new Error(`Token exchange failed (${res.status}): ${detail}`);
122
+ }
123
+ return await res.json();
124
+ }
125
+ async function createAuthSession(config) {
126
+ let { token, expiresAt } = await exchangeKeyForToken(config);
127
+ async function ensureFresh() {
128
+ if (Date.now() < expiresAt - REFRESH_MARGIN_MS) return;
129
+ const next = await exchangeKeyForToken(config);
130
+ token = next.token;
131
+ expiresAt = next.expiresAt;
132
+ }
133
+ return { getToken: () => token, ensureFresh };
134
+ }
135
+
136
+ // src/http-client.ts
137
+ var ApiError = class extends Error {
138
+ status;
139
+ code;
140
+ constructor(message, opts = {}) {
141
+ super(message);
142
+ this.name = "ApiError";
143
+ this.status = opts.status;
144
+ this.code = opts.code;
145
+ }
146
+ };
147
+ async function apiRequest(baseUrl, path, opts) {
148
+ if (typeof fetch === "undefined") {
149
+ throw new ApiError("global fetch is unavailable. Use Node >=18.18 without --no-experimental-fetch.", { code: "NO_FETCH" });
150
+ }
151
+ const headers = {
152
+ "User-Agent": `athletelog-cli/${getVersion()}`,
153
+ // sent on EVERY request (success criterion)
154
+ Authorization: `Bearer ${opts.token}`,
155
+ // never logged
156
+ Accept: "application/json"
157
+ };
158
+ if (opts.body !== void 0) headers["Content-Type"] = "application/json";
159
+ let res;
160
+ try {
161
+ res = await fetch(new URL(path, baseUrl), {
162
+ method: opts.method ?? "GET",
163
+ headers,
164
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
165
+ });
166
+ } catch (err) {
167
+ const e = err;
168
+ const cause = e.cause?.code ? ` (${e.cause.code})` : "";
169
+ throw new ApiError(`Network error: ${e.message ?? String(err)}${cause}`, { code: "NETWORK" });
170
+ }
171
+ const sunset = res.headers.get("Sunset");
172
+ if (res.headers.get("Deprecation") || sunset) {
173
+ let msg = "Warning: this CLI uses a deprecated API endpoint.";
174
+ if (sunset) msg += ` It will stop working after ${sunset}.`;
175
+ process.stderr.write(msg + " Upgrade: npm i -g @athletelog/cli\n");
176
+ }
177
+ if (res.status === 410) {
178
+ throw new ApiError("This API endpoint was removed (410 Gone). Upgrade: npm i -g @athletelog/cli", { status: 410, code: "GONE" });
179
+ }
180
+ if (!res.ok) {
181
+ const text = await res.text();
182
+ let code;
183
+ let message = text;
184
+ try {
185
+ const parsed = JSON.parse(text);
186
+ message = parsed?.error?.message ?? parsed?.error?.code ?? text;
187
+ code = parsed?.error?.code;
188
+ } catch {
189
+ }
190
+ throw new ApiError(`Request failed (${res.status}): ${message}`, { status: res.status, code });
191
+ }
192
+ if (res.status === 204) return void 0;
193
+ return await res.json();
194
+ }
195
+
196
+ // src/commands/athletes-list.ts
197
+ async function runAthletesList(args) {
198
+ const config = loadConfig();
199
+ const session = await createAuthSession(config);
200
+ const athletes = await apiRequest(config.baseUrl, "/athletelog/v1/athletes", { token: session.getToken() });
201
+ if (args.json) {
202
+ process.stdout.write(JSON.stringify(athletes, null, 2) + "\n");
203
+ return;
204
+ }
205
+ if (athletes.length === 0) {
206
+ process.stderr.write("No active athletes.\n");
207
+ return;
208
+ }
209
+ for (const a of athletes) {
210
+ process.stdout.write(`${a._id} ${a.name} ${a.email ?? ""}
211
+ `);
212
+ }
213
+ }
214
+
215
+ // src/commands/checkin-import.ts
216
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
217
+
218
+ // src/skinfold-sites.ts
219
+ var SKINFOLD_SITES = [
220
+ "chin",
221
+ "cheek",
222
+ "pectoral",
223
+ "bicep",
224
+ "midAxilla",
225
+ "supraIliac",
226
+ "umbilical",
227
+ "triceps",
228
+ "subScapular",
229
+ "lowerback",
230
+ "knee",
231
+ "calf",
232
+ "quad",
233
+ "hamstring"
234
+ ];
235
+
236
+ // src/commands/checkin-import.ts
237
+ function toNumber(value) {
238
+ if (typeof value !== "number" || Number.isNaN(value)) return void 0;
239
+ return value;
240
+ }
241
+ async function runCheckinImport(args) {
242
+ const config = loadConfig();
243
+ const session = await createAuthSession(config);
244
+ const targetUserId = args.athlete;
245
+ if (!existsSync2(args.file)) throw new Error(`File not found: ${args.file}`);
246
+ const records = JSON.parse(readFileSync3(args.file, "utf8"));
247
+ if (!Array.isArray(records)) throw new Error("--file must contain a JSON array of records");
248
+ let created = 0, updated = 0, failed = 0;
249
+ for (let i = 0; i < records.length; i++) {
250
+ const rec = records[i];
251
+ try {
252
+ await session.ensureFresh();
253
+ const sites = {};
254
+ for (const site of SKINFOLD_SITES) {
255
+ const n = toNumber(rec[site]);
256
+ if (n !== void 0) sites[site] = n;
257
+ }
258
+ const checkInDate = typeof rec.checkInDate === "string" ? rec.checkInDate : void 0;
259
+ if (checkInDate === void 0) throw new Error("record missing checkInDate");
260
+ const checkInTime = typeof rec.checkInTime === "string" ? rec.checkInTime : void 0;
261
+ const bodyWeightKg = toNumber(rec.bodyWeightKg);
262
+ const result = await apiRequest(config.baseUrl, "/athletelog/v1/checkins/import", {
263
+ method: "POST",
264
+ token: session.getToken(),
265
+ body: {
266
+ targetUserId,
267
+ checkInDate,
268
+ ...checkInTime ? { checkInTime } : {},
269
+ ...bodyWeightKg !== void 0 ? { bodyWeightKg } : {},
270
+ ...sites
271
+ }
272
+ });
273
+ if (result.created) created++;
274
+ else updated++;
275
+ } catch (err) {
276
+ failed++;
277
+ const message = err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
278
+ process.stderr.write(`Record ${i} failed: ${message}
279
+ `);
280
+ }
281
+ }
282
+ const summary = { created, updated, failed };
283
+ if (args.json) {
284
+ process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
285
+ return;
286
+ }
287
+ process.stdout.write(`Import complete: ${created} created, ${updated} updated, ${failed} failed
288
+ `);
289
+ }
290
+
291
+ // src/prompt.ts
292
+ import { createInterface } from "readline";
293
+ function isInteractive() {
294
+ return process.stdin.isTTY === true;
295
+ }
296
+ function promptSecret(query) {
297
+ return new Promise((resolve) => {
298
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
299
+ let muted = false;
300
+ rl._writeToOutput = (chunk) => {
301
+ if (!muted) process.stdout.write(chunk);
302
+ };
303
+ rl.question(query, (answer) => {
304
+ process.stdout.write("\n");
305
+ rl.close();
306
+ resolve(answer.trim());
307
+ });
308
+ muted = true;
309
+ });
310
+ }
311
+
312
+ // src/commands/login.ts
313
+ async function runLogin(args) {
314
+ const envKey = process.env.ATHLETELOG_API_KEY;
315
+ let key = args.key ?? envKey;
316
+ const fromEnvVar = key !== void 0 && args.key === void 0;
317
+ if (!key) {
318
+ if (!isInteractive()) {
319
+ throw new Error("No API key. In a non-interactive shell, pass --key or set ATHLETELOG_API_KEY.");
320
+ }
321
+ key = await promptSecret("Enter API key: ");
322
+ }
323
+ if (!key) throw new Error("No API key provided.");
324
+ const overrides = { apiKey: key, url: args.url, env: args.env };
325
+ const config = loadConfig(overrides);
326
+ const session = await createAuthSession(config);
327
+ const me = await apiRequest(config.baseUrl, "/athletelog/v1/me", { token: session.getToken() });
328
+ process.stdout.write(`\u2713 Logged in as Coach ${me.name} (${me.env})
329
+ `);
330
+ if (!fromEnvVar) {
331
+ const storedEnv = args.env ?? (config.env === "unknown" ? void 0 : config.env);
332
+ writeConfig({
333
+ apiKey: key,
334
+ ...storedEnv ? { env: storedEnv } : {},
335
+ ...args.url ? { url: args.url } : {}
336
+ });
337
+ }
338
+ }
339
+
340
+ // src/commands/whoami.ts
341
+ async function runWhoami(args) {
342
+ const config = loadConfig();
343
+ const session = await createAuthSession(config);
344
+ const me = await apiRequest(config.baseUrl, "/athletelog/v1/me", { token: session.getToken() });
345
+ if (args.json) {
346
+ process.stdout.write(JSON.stringify({ name: me.name, email: me.email, env: me.env, baseUrl: config.baseUrl }, null, 2) + "\n");
347
+ return;
348
+ }
349
+ process.stdout.write(`Coach ${me.name} (${me.env}) @ ${config.baseUrl}
350
+ `);
351
+ }
352
+
353
+ // src/commands/logout.ts
354
+ function runLogout() {
355
+ clearConfig();
356
+ process.stdout.write("Logged out.\n");
357
+ }
358
+
359
+ // src/index.ts
360
+ var VALID_ENVS = ["dev", "staging", "prod"];
361
+ var FULL_USAGE = `athletelog -- AthleteLog CLI
362
+ Usage:
363
+ athletelog login [--key alog_\u2026] [--env dev|staging|prod] [--url <base>]
364
+ athletelog whoami [--json]
365
+ athletelog logout
366
+ athletelog athletes list [--json]
367
+ athletelog checkin import --athlete <id> --file <records.json> [--json]
368
+ `;
369
+ function usageFor(group, action) {
370
+ if (group === "checkin" && action === "import") {
371
+ return `athletelog checkin import --athlete <id> --file <records.json> [--json]
372
+ --athlete target athlete userId (applied to every record; NOT read from the file)
373
+ --file path to a JSON array of records
374
+ `;
375
+ }
376
+ if (group === "athletes" && action === "list") return `athletelog athletes list [--json]
377
+ `;
378
+ if (group === "login") return `athletelog login [--key alog_\u2026] [--env dev|staging|prod] [--url <base>]
379
+ `;
380
+ return FULL_USAGE;
381
+ }
382
+ async function main() {
383
+ let parsed;
384
+ try {
385
+ parsed = parseArgs({
386
+ allowPositionals: true,
387
+ strict: true,
388
+ options: {
389
+ json: { type: "boolean", default: false },
390
+ help: { type: "boolean", default: false },
391
+ athlete: { type: "string" },
392
+ file: { type: "string" },
393
+ key: { type: "string" },
394
+ url: { type: "string" },
395
+ env: { type: "string" }
396
+ }
397
+ });
398
+ } catch (err) {
399
+ const message = err instanceof Error ? err.message : String(err);
400
+ process.stderr.write(`Argument error: ${message}
401
+ ${usageFor()}`);
402
+ process.exit(1);
403
+ }
404
+ const { values, positionals } = parsed;
405
+ const [group, action] = positionals;
406
+ const json = values.json === true;
407
+ if (values.help === true || group === "help") {
408
+ process.stdout.write(usageFor(group === "help" ? action : group, action));
409
+ process.exit(0);
410
+ }
411
+ let env;
412
+ if (typeof values.env === "string") {
413
+ if (!VALID_ENVS.includes(values.env)) {
414
+ throw new Error(`--env must be one of: ${VALID_ENVS.join(", ")}`);
415
+ }
416
+ env = values.env;
417
+ }
418
+ if (group === "login") {
419
+ await runLogin({ key: values.key, url: values.url, env });
420
+ return;
421
+ }
422
+ if (group === "whoami") {
423
+ await runWhoami({ json });
424
+ return;
425
+ }
426
+ if (group === "logout") {
427
+ runLogout();
428
+ return;
429
+ }
430
+ if (group === "athletes" && action === "list") {
431
+ await runAthletesList({ json });
432
+ return;
433
+ }
434
+ if (group === "checkin" && action === "import") {
435
+ if (!values.athlete || !values.file) {
436
+ throw new Error("checkin import requires --athlete and --file");
437
+ }
438
+ await runCheckinImport({ json, athlete: values.athlete, file: values.file });
439
+ return;
440
+ }
441
+ process.stderr.write(`Unknown command: ${group ?? ""} ${action ?? ""}
442
+ `);
443
+ process.exit(1);
444
+ }
445
+ main().catch((err) => {
446
+ const message = err instanceof Error ? err.message : String(err);
447
+ process.stderr.write(`Error: ${message}
448
+ `);
449
+ process.exit(1);
450
+ });
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@athletelog/cli",
3
+ "version": "0.1.0-beta.1",
4
+ "type": "module",
5
+ "bin": {
6
+ "athletelog": "./dist/index.js"
7
+ },
8
+ "files": ["dist"],
9
+ "engines": {
10
+ "node": ">=18.18"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "tag": "beta"
15
+ },
16
+ "scripts": {
17
+ "build": "tsup",
18
+ "prepack": "tsup"
19
+ },
20
+ "devDependencies": {
21
+ "tsup": "8.5.0"
22
+ }
23
+ }