kahunas-cli 1.3.0 → 1.4.0
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/README.md +1 -1
- package/dist/cli.js +133 -107
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ pnpm kahunas serve
|
|
|
29
29
|
|
|
30
30
|
Open `http://127.0.0.1:3000`.
|
|
31
31
|
|
|
32
|
-
If `auth.json` is missing, `sync` will prompt for credentials and save them.
|
|
32
|
+
If `auth.json` is missing, `sync` will prompt for credentials and save them after a successful login.
|
|
33
33
|
|
|
34
34
|
## Advanced usage
|
|
35
35
|
|
package/dist/cli.js
CHANGED
|
@@ -439,6 +439,24 @@ function askQuestion(prompt, output = process.stdout) {
|
|
|
439
439
|
function waitForEnter(prompt, output = process.stdout) {
|
|
440
440
|
return askQuestion(prompt, output).then(() => void 0);
|
|
441
441
|
}
|
|
442
|
+
function askHiddenQuestion(prompt, output = process.stderr) {
|
|
443
|
+
return new Promise((resolve) => {
|
|
444
|
+
const rl = node_readline.createInterface({
|
|
445
|
+
input: process.stdin,
|
|
446
|
+
output,
|
|
447
|
+
terminal: true
|
|
448
|
+
});
|
|
449
|
+
const writable = rl;
|
|
450
|
+
const originalWrite = writable._writeToOutput.bind(rl);
|
|
451
|
+
writable._writeToOutput = (value) => {
|
|
452
|
+
if (value.includes("\n") || value.includes("\r")) originalWrite(value);
|
|
453
|
+
};
|
|
454
|
+
rl.question(prompt, (answer) => {
|
|
455
|
+
rl.close();
|
|
456
|
+
resolve(answer.trim());
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
}
|
|
442
460
|
function debugLog(enabled, message) {
|
|
443
461
|
logDebug(enabled, message);
|
|
444
462
|
}
|
|
@@ -597,8 +615,8 @@ function normalizePath(pathname) {
|
|
|
597
615
|
function normalizeToken(token) {
|
|
598
616
|
return token.trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").replace(/^bearer\s+/i, "");
|
|
599
617
|
}
|
|
600
|
-
function resolveStoredAuth() {
|
|
601
|
-
const auth = readAuthConfig();
|
|
618
|
+
function resolveStoredAuth(override) {
|
|
619
|
+
const auth = override ?? readAuthConfig();
|
|
602
620
|
if (!auth) return;
|
|
603
621
|
const login = auth.username ?? auth.email;
|
|
604
622
|
if (!login || !auth.password) throw new Error(`Invalid auth.json at ${AUTH_PATH}. Expected \"username\" or \"email\" and \"password\".`);
|
|
@@ -719,11 +737,11 @@ async function triggerWorkoutCapture(page, webOrigin, plans, debug) {
|
|
|
719
737
|
debugLog(debug, `Workout capture plans=${plans.length}`);
|
|
720
738
|
return plans.length > 0;
|
|
721
739
|
}
|
|
722
|
-
async function captureWorkoutsFromBrowser(options, config) {
|
|
740
|
+
async function captureWorkoutsFromBrowser(options, config, authOverride) {
|
|
723
741
|
const webBaseUrl = resolveWebBaseUrl(options, config);
|
|
724
742
|
const headless = config.headless ?? true;
|
|
725
743
|
const debug = config.debug === true;
|
|
726
|
-
const storedAuth = resolveStoredAuth();
|
|
744
|
+
const storedAuth = resolveStoredAuth(authOverride);
|
|
727
745
|
const browser = await (await import("playwright")).chromium.launch({ headless });
|
|
728
746
|
const context = await browser.newContext();
|
|
729
747
|
const plans = [];
|
|
@@ -2243,6 +2261,104 @@ async function handleWorkout(positionals, options) {
|
|
|
2243
2261
|
}
|
|
2244
2262
|
return programDetails;
|
|
2245
2263
|
};
|
|
2264
|
+
const startWorkoutServer = async () => {
|
|
2265
|
+
const host = "127.0.0.1";
|
|
2266
|
+
const port = 3e3;
|
|
2267
|
+
const limit = 1;
|
|
2268
|
+
const cacheTtlMs = 3e4;
|
|
2269
|
+
const loadSummary = async () => {
|
|
2270
|
+
const { text, payload, timezone } = await fetchWorkoutEventsPayload();
|
|
2271
|
+
if (!Array.isArray(payload)) throw new Error(`Unexpected calendar response: ${text.slice(0, 200)}`);
|
|
2272
|
+
const sorted = sortWorkoutEvents(filterWorkoutEvents(payload));
|
|
2273
|
+
const bounded = limit > 0 ? sorted.slice(-limit) : sorted;
|
|
2274
|
+
const programDetails = await buildProgramDetails(sorted);
|
|
2275
|
+
const formatted = formatWorkoutEventsOutput(bounded, programDetails, {
|
|
2276
|
+
timezone,
|
|
2277
|
+
program: void 0,
|
|
2278
|
+
workout: void 0
|
|
2279
|
+
});
|
|
2280
|
+
const summary = formatted.events[0];
|
|
2281
|
+
const programUuid = summary?.program?.uuid ?? (bounded[0] && typeof bounded[0] === "object" ? bounded[0].program : void 0);
|
|
2282
|
+
return {
|
|
2283
|
+
formatted,
|
|
2284
|
+
days: summarizeWorkoutProgramDays(programUuid ? programDetails[programUuid] : void 0),
|
|
2285
|
+
summary,
|
|
2286
|
+
timezone
|
|
2287
|
+
};
|
|
2288
|
+
};
|
|
2289
|
+
let cached;
|
|
2290
|
+
let summaryInFlight = null;
|
|
2291
|
+
const getSummary = async (forceRefresh) => {
|
|
2292
|
+
if (!forceRefresh && cached && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.data;
|
|
2293
|
+
if (!forceRefresh && summaryInFlight) return summaryInFlight;
|
|
2294
|
+
const pending = loadSummary().finally(() => {
|
|
2295
|
+
summaryInFlight = null;
|
|
2296
|
+
});
|
|
2297
|
+
summaryInFlight = pending;
|
|
2298
|
+
const data = await pending;
|
|
2299
|
+
cached = {
|
|
2300
|
+
data,
|
|
2301
|
+
fetchedAt: Date.now()
|
|
2302
|
+
};
|
|
2303
|
+
return data;
|
|
2304
|
+
};
|
|
2305
|
+
(0, node_http.createServer)(async (req, res) => {
|
|
2306
|
+
try {
|
|
2307
|
+
const url = new URL(req.url ?? "/", `http://${host}:${port}`);
|
|
2308
|
+
const wantsRefresh = url.searchParams.get("refresh") === "1";
|
|
2309
|
+
if (url.pathname === "/api/workout") {
|
|
2310
|
+
const data$1 = await getSummary(wantsRefresh);
|
|
2311
|
+
res.statusCode = 200;
|
|
2312
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
2313
|
+
res.setHeader("cache-control", "no-store");
|
|
2314
|
+
res.end(JSON.stringify(data$1.formatted, null, 2));
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
if (url.pathname === "/favicon.ico") {
|
|
2318
|
+
res.statusCode = 204;
|
|
2319
|
+
res.end();
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
if (url.pathname !== "/") {
|
|
2323
|
+
res.statusCode = 404;
|
|
2324
|
+
res.end("Not found");
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
const data = await getSummary(wantsRefresh);
|
|
2328
|
+
const dayParam = url.searchParams.get("day");
|
|
2329
|
+
const selectedDayIndex = resolveSelectedDayIndex(data.days, data.summary?.workout_day?.day_index, data.summary?.workout_day?.day_label, dayParam);
|
|
2330
|
+
const html = renderWorkoutPage({
|
|
2331
|
+
summary: data.summary,
|
|
2332
|
+
days: data.days,
|
|
2333
|
+
selectedDayIndex,
|
|
2334
|
+
timezone: data.timezone,
|
|
2335
|
+
apiPath: "/api/workout",
|
|
2336
|
+
refreshPath: "/?refresh=1",
|
|
2337
|
+
isLatest: limit === 1
|
|
2338
|
+
});
|
|
2339
|
+
res.statusCode = 200;
|
|
2340
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
2341
|
+
res.setHeader("cache-control", "no-store");
|
|
2342
|
+
res.end(html);
|
|
2343
|
+
} catch (error) {
|
|
2344
|
+
res.statusCode = 500;
|
|
2345
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
2346
|
+
res.end(error instanceof Error ? error.message : "Server error");
|
|
2347
|
+
}
|
|
2348
|
+
}).listen(port, host, () => {
|
|
2349
|
+
const cache = readWorkoutCache();
|
|
2350
|
+
const freshConfig = readConfig();
|
|
2351
|
+
const lastSync = cache?.updatedAt ?? "none";
|
|
2352
|
+
const tokenExpiry = freshConfig.tokenExpiresAt ?? "unknown";
|
|
2353
|
+
const tokenUpdatedAt = freshConfig.tokenUpdatedAt ?? "unknown";
|
|
2354
|
+
logInfo(`Local workout server running at http://${host}:${port}`);
|
|
2355
|
+
logInfo(`JSON endpoint at http://${host}:${port}/api/workout`);
|
|
2356
|
+
logInfo(`Config: ${CONFIG_PATH}`);
|
|
2357
|
+
logInfo(`Last workout sync: ${lastSync}`);
|
|
2358
|
+
logInfo(`Token expiry: ${tokenExpiry}`);
|
|
2359
|
+
if (tokenExpiry === "unknown" && tokenUpdatedAt !== "unknown") logInfo(`Token updated at: ${tokenUpdatedAt}`);
|
|
2360
|
+
});
|
|
2361
|
+
};
|
|
2246
2362
|
const resolveSelectedDayIndex = (days, eventDayIndex, eventDayLabel, dayParam) => {
|
|
2247
2363
|
const parseOptionalInt = (value) => {
|
|
2248
2364
|
if (!value) return;
|
|
@@ -2368,102 +2484,7 @@ async function handleWorkout(positionals, options) {
|
|
|
2368
2484
|
return;
|
|
2369
2485
|
}
|
|
2370
2486
|
if (action === "serve") {
|
|
2371
|
-
|
|
2372
|
-
const port = 3e3;
|
|
2373
|
-
const limit = 1;
|
|
2374
|
-
const cacheTtlMs = 3e4;
|
|
2375
|
-
const loadSummary = async () => {
|
|
2376
|
-
const { text, payload, timezone } = await fetchWorkoutEventsPayload();
|
|
2377
|
-
if (!Array.isArray(payload)) throw new Error(`Unexpected calendar response: ${text.slice(0, 200)}`);
|
|
2378
|
-
const sorted = sortWorkoutEvents(filterWorkoutEvents(payload));
|
|
2379
|
-
const bounded = limit > 0 ? sorted.slice(-limit) : sorted;
|
|
2380
|
-
const programDetails = await buildProgramDetails(sorted);
|
|
2381
|
-
const formatted = formatWorkoutEventsOutput(bounded, programDetails, {
|
|
2382
|
-
timezone,
|
|
2383
|
-
program: void 0,
|
|
2384
|
-
workout: void 0
|
|
2385
|
-
});
|
|
2386
|
-
const summary = formatted.events[0];
|
|
2387
|
-
const programUuid = summary?.program?.uuid ?? (bounded[0] && typeof bounded[0] === "object" ? bounded[0].program : void 0);
|
|
2388
|
-
return {
|
|
2389
|
-
formatted,
|
|
2390
|
-
days: summarizeWorkoutProgramDays(programUuid ? programDetails[programUuid] : void 0),
|
|
2391
|
-
summary,
|
|
2392
|
-
timezone
|
|
2393
|
-
};
|
|
2394
|
-
};
|
|
2395
|
-
let cached;
|
|
2396
|
-
let summaryInFlight = null;
|
|
2397
|
-
const getSummary = async (forceRefresh) => {
|
|
2398
|
-
if (!forceRefresh && cached && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.data;
|
|
2399
|
-
if (!forceRefresh && summaryInFlight) return summaryInFlight;
|
|
2400
|
-
const pending = loadSummary().finally(() => {
|
|
2401
|
-
summaryInFlight = null;
|
|
2402
|
-
});
|
|
2403
|
-
summaryInFlight = pending;
|
|
2404
|
-
const data = await pending;
|
|
2405
|
-
cached = {
|
|
2406
|
-
data,
|
|
2407
|
-
fetchedAt: Date.now()
|
|
2408
|
-
};
|
|
2409
|
-
return data;
|
|
2410
|
-
};
|
|
2411
|
-
(0, node_http.createServer)(async (req, res) => {
|
|
2412
|
-
try {
|
|
2413
|
-
const url = new URL(req.url ?? "/", `http://${host}:${port}`);
|
|
2414
|
-
const wantsRefresh = url.searchParams.get("refresh") === "1";
|
|
2415
|
-
if (url.pathname === "/api/workout") {
|
|
2416
|
-
const data$1 = await getSummary(wantsRefresh);
|
|
2417
|
-
res.statusCode = 200;
|
|
2418
|
-
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
2419
|
-
res.setHeader("cache-control", "no-store");
|
|
2420
|
-
res.end(JSON.stringify(data$1.formatted, null, 2));
|
|
2421
|
-
return;
|
|
2422
|
-
}
|
|
2423
|
-
if (url.pathname === "/favicon.ico") {
|
|
2424
|
-
res.statusCode = 204;
|
|
2425
|
-
res.end();
|
|
2426
|
-
return;
|
|
2427
|
-
}
|
|
2428
|
-
if (url.pathname !== "/") {
|
|
2429
|
-
res.statusCode = 404;
|
|
2430
|
-
res.end("Not found");
|
|
2431
|
-
return;
|
|
2432
|
-
}
|
|
2433
|
-
const data = await getSummary(wantsRefresh);
|
|
2434
|
-
const dayParam = url.searchParams.get("day");
|
|
2435
|
-
const selectedDayIndex = resolveSelectedDayIndex(data.days, data.summary?.workout_day?.day_index, data.summary?.workout_day?.day_label, dayParam);
|
|
2436
|
-
const html = renderWorkoutPage({
|
|
2437
|
-
summary: data.summary,
|
|
2438
|
-
days: data.days,
|
|
2439
|
-
selectedDayIndex,
|
|
2440
|
-
timezone: data.timezone,
|
|
2441
|
-
apiPath: "/api/workout",
|
|
2442
|
-
refreshPath: "/?refresh=1",
|
|
2443
|
-
isLatest: limit === 1
|
|
2444
|
-
});
|
|
2445
|
-
res.statusCode = 200;
|
|
2446
|
-
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
2447
|
-
res.setHeader("cache-control", "no-store");
|
|
2448
|
-
res.end(html);
|
|
2449
|
-
} catch (error) {
|
|
2450
|
-
res.statusCode = 500;
|
|
2451
|
-
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
2452
|
-
res.end(error instanceof Error ? error.message : "Server error");
|
|
2453
|
-
}
|
|
2454
|
-
}).listen(port, host, () => {
|
|
2455
|
-
const cache = readWorkoutCache();
|
|
2456
|
-
const freshConfig = readConfig();
|
|
2457
|
-
const lastSync = cache?.updatedAt ?? "none";
|
|
2458
|
-
const tokenExpiry = freshConfig.tokenExpiresAt ?? "unknown";
|
|
2459
|
-
const tokenUpdatedAt = freshConfig.tokenUpdatedAt ?? "unknown";
|
|
2460
|
-
logInfo(`Local workout server running at http://${host}:${port}`);
|
|
2461
|
-
logInfo(`JSON endpoint at http://${host}:${port}/api/workout`);
|
|
2462
|
-
logInfo(`Config: ${CONFIG_PATH}`);
|
|
2463
|
-
logInfo(`Last workout sync: ${lastSync}`);
|
|
2464
|
-
logInfo(`Token expiry: ${tokenExpiry}`);
|
|
2465
|
-
if (tokenExpiry === "unknown" && tokenUpdatedAt !== "unknown") logInfo(`Token updated at: ${tokenUpdatedAt}`);
|
|
2466
|
-
});
|
|
2487
|
+
await startWorkoutServer();
|
|
2467
2488
|
return;
|
|
2468
2489
|
}
|
|
2469
2490
|
if (action === "sync") {
|
|
@@ -2471,21 +2492,22 @@ async function handleWorkout(positionals, options) {
|
|
|
2471
2492
|
const hasAuthConfig = !!authConfig && !!authConfig.password && (!!authConfig.email || !!authConfig.username);
|
|
2472
2493
|
const tokenUpdatedAt = config.tokenUpdatedAt ?? void 0;
|
|
2473
2494
|
const tokenExpiry = token && tokenUpdatedAt ? resolveTokenExpiry(token, tokenUpdatedAt) : null;
|
|
2474
|
-
|
|
2495
|
+
const hasValidToken = !!tokenExpiry && Date.now() < Date.parse(tokenExpiry);
|
|
2496
|
+
let pendingAuth;
|
|
2497
|
+
if (!hasValidToken && !hasAuthConfig) {
|
|
2475
2498
|
if (!process.stdin.isTTY) throw new Error("Missing auth credentials. Create ~/.config/kahunas/auth.json or run 'kahunas sync' in a terminal.");
|
|
2476
2499
|
const login = await askQuestion("Email or username: ", process.stderr);
|
|
2477
2500
|
if (!login) throw new Error("Missing email/username for login.");
|
|
2478
|
-
const password = await
|
|
2501
|
+
const password = await askHiddenQuestion("Password: ", process.stderr);
|
|
2479
2502
|
if (!password) throw new Error("Missing password for login.");
|
|
2480
2503
|
const isEmail = login.includes("@");
|
|
2481
|
-
|
|
2504
|
+
pendingAuth = {
|
|
2482
2505
|
email: isEmail ? login : void 0,
|
|
2483
2506
|
username: isEmail ? void 0 : login,
|
|
2484
2507
|
password
|
|
2485
|
-
}
|
|
2486
|
-
console.error(`Saved credentials to ${AUTH_PATH}`);
|
|
2508
|
+
};
|
|
2487
2509
|
}
|
|
2488
|
-
const captured = await captureWorkoutsFromBrowser(options, config);
|
|
2510
|
+
const captured = await captureWorkoutsFromBrowser(options, config, pendingAuth);
|
|
2489
2511
|
const nextConfig = { ...config };
|
|
2490
2512
|
if (captured.token) {
|
|
2491
2513
|
nextConfig.token = captured.token;
|
|
@@ -2507,8 +2529,12 @@ async function handleWorkout(positionals, options) {
|
|
|
2507
2529
|
path: WORKOUT_CACHE_PATH
|
|
2508
2530
|
}
|
|
2509
2531
|
}, null, 2));
|
|
2532
|
+
if (pendingAuth && captured.token) {
|
|
2533
|
+
writeAuthConfig(pendingAuth);
|
|
2534
|
+
console.error(`Saved credentials to ${AUTH_PATH}`);
|
|
2535
|
+
} else if (pendingAuth) console.error("Login was not detected; credentials were not saved.");
|
|
2510
2536
|
if (process.stdin.isTTY) {
|
|
2511
|
-
if ((await askQuestion("Start the preview server now? (y/N): ", process.stderr)).toLowerCase().startsWith("y")) await
|
|
2537
|
+
if ((await askQuestion("Start the preview server now? (y/N): ", process.stderr)).toLowerCase().startsWith("y")) await startWorkoutServer();
|
|
2512
2538
|
}
|
|
2513
2539
|
return;
|
|
2514
2540
|
}
|