@drakulavich/oura-cli 0.5.2 → 0.7.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/CHANGELOG.md +37 -0
- package/README.md +17 -4
- package/dist/index.js +754 -138
- package/docs/schemas/battery.json +21 -0
- package/docs/schemas/doctor.json +1 -1
- package/docs/schemas/resilience.json +26 -0
- package/docs/schemas/rest-mode.json +26 -0
- package/docs/schemas/ring.json +20 -0
- package/docs/schemas/session.json +26 -0
- package/docs/schemas/sleep-time.json +26 -0
- package/docs/schemas/tags.json +26 -0
- package/docs/schemas/vo2max.json +26 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -557,8 +557,26 @@ var init_source = __esm(() => {
|
|
|
557
557
|
source_default = chalk;
|
|
558
558
|
});
|
|
559
559
|
|
|
560
|
-
// src/
|
|
560
|
+
// src/lib/apply-color-mode.ts
|
|
561
561
|
init_source();
|
|
562
|
+
|
|
563
|
+
// src/lib/color-mode.ts
|
|
564
|
+
function forcesColor(value) {
|
|
565
|
+
return value !== undefined && value !== "" && value !== "0" && value !== "false";
|
|
566
|
+
}
|
|
567
|
+
function shouldDisableColor(argv, env, isTty) {
|
|
568
|
+
if (argv.includes("--no-color") || env.NO_COLOR)
|
|
569
|
+
return true;
|
|
570
|
+
return !isTty && !forcesColor(env.FORCE_COLOR);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/lib/apply-color-mode.ts
|
|
574
|
+
if (shouldDisableColor(process.argv, process.env, process.stdout.isTTY === true)) {
|
|
575
|
+
process.env.NO_COLOR = "1";
|
|
576
|
+
source_default.level = 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/index.ts
|
|
562
580
|
import { readFileSync as readFileSync2 } from "fs";
|
|
563
581
|
|
|
564
582
|
// node_modules/citty/dist/_chunks/libs/scule.mjs
|
|
@@ -1180,6 +1198,14 @@ import { resolve, dirname } from "path";
|
|
|
1180
1198
|
import { homedir } from "os";
|
|
1181
1199
|
import { chmodSync, existsSync, mkdirSync } from "fs";
|
|
1182
1200
|
|
|
1201
|
+
// src/lib/require-value.ts
|
|
1202
|
+
function requireValue(value, source, fallback) {
|
|
1203
|
+
if (value.trim() === "") {
|
|
1204
|
+
throw new CliError("BAD_ARGS", `${source} has no value`, `Pass a value, or remove ${source} to fall back to ${fallback}.`);
|
|
1205
|
+
}
|
|
1206
|
+
return value;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1183
1209
|
// src/db/migrations.ts
|
|
1184
1210
|
var MIGRATIONS = [
|
|
1185
1211
|
{
|
|
@@ -1309,26 +1335,108 @@ CREATE VIEW IF NOT EXISTS v_sleep_detail AS
|
|
|
1309
1335
|
average_hrv, average_heart_rate as avg_hr, lowest_heart_rate as lowest_hr, efficiency
|
|
1310
1336
|
FROM sleep_model ORDER BY day DESC;
|
|
1311
1337
|
`
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
version: 3,
|
|
1341
|
+
sql: `
|
|
1342
|
+
CREATE TABLE IF NOT EXISTS daily_resilience (
|
|
1343
|
+
id TEXT PRIMARY KEY,
|
|
1344
|
+
day TEXT UNIQUE,
|
|
1345
|
+
level TEXT,
|
|
1346
|
+
sleep_recovery REAL,
|
|
1347
|
+
daytime_recovery REAL,
|
|
1348
|
+
stress REAL
|
|
1349
|
+
);
|
|
1350
|
+
CREATE TABLE IF NOT EXISTS sleep_time (
|
|
1351
|
+
id TEXT PRIMARY KEY,
|
|
1352
|
+
day TEXT UNIQUE,
|
|
1353
|
+
status TEXT,
|
|
1354
|
+
recommendation TEXT,
|
|
1355
|
+
bedtime_start_offset INTEGER,
|
|
1356
|
+
bedtime_end_offset INTEGER,
|
|
1357
|
+
day_tz INTEGER
|
|
1358
|
+
);
|
|
1359
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
1360
|
+
id TEXT PRIMARY KEY,
|
|
1361
|
+
day TEXT,
|
|
1362
|
+
type TEXT,
|
|
1363
|
+
mood TEXT,
|
|
1364
|
+
start_datetime TEXT,
|
|
1365
|
+
end_datetime TEXT,
|
|
1366
|
+
heart_rate TEXT,
|
|
1367
|
+
heart_rate_variability TEXT,
|
|
1368
|
+
motion_count TEXT
|
|
1369
|
+
);
|
|
1370
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_day ON sessions(day);
|
|
1371
|
+
CREATE TABLE IF NOT EXISTS rest_mode_periods (
|
|
1372
|
+
id TEXT PRIMARY KEY,
|
|
1373
|
+
day TEXT,
|
|
1374
|
+
end_day TEXT,
|
|
1375
|
+
start_time TEXT,
|
|
1376
|
+
end_time TEXT,
|
|
1377
|
+
episodes TEXT
|
|
1378
|
+
);
|
|
1379
|
+
CREATE INDEX IF NOT EXISTS idx_rest_mode_periods_day ON rest_mode_periods(day);
|
|
1380
|
+
CREATE TABLE IF NOT EXISTS enhanced_tags (
|
|
1381
|
+
id TEXT PRIMARY KEY,
|
|
1382
|
+
day TEXT,
|
|
1383
|
+
end_day TEXT,
|
|
1384
|
+
start_time TEXT,
|
|
1385
|
+
end_time TEXT,
|
|
1386
|
+
tag_type_code TEXT,
|
|
1387
|
+
comment TEXT,
|
|
1388
|
+
custom_name TEXT
|
|
1389
|
+
);
|
|
1390
|
+
CREATE INDEX IF NOT EXISTS idx_enhanced_tags_day ON enhanced_tags(day);
|
|
1391
|
+
CREATE TABLE IF NOT EXISTS ring_configuration (
|
|
1392
|
+
id TEXT PRIMARY KEY,
|
|
1393
|
+
color TEXT,
|
|
1394
|
+
design TEXT,
|
|
1395
|
+
firmware_version TEXT,
|
|
1396
|
+
hardware_type TEXT,
|
|
1397
|
+
set_up_at TEXT,
|
|
1398
|
+
size INTEGER
|
|
1399
|
+
);
|
|
1400
|
+
CREATE TABLE IF NOT EXISTS ring_battery_level (
|
|
1401
|
+
timestamp TEXT,
|
|
1402
|
+
level INTEGER,
|
|
1403
|
+
charging INTEGER,
|
|
1404
|
+
in_charger INTEGER,
|
|
1405
|
+
day TEXT
|
|
1406
|
+
);
|
|
1407
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ring_battery_level_unique ON ring_battery_level(timestamp);
|
|
1408
|
+
CREATE INDEX IF NOT EXISTS idx_ring_battery_level_day ON ring_battery_level(day);
|
|
1409
|
+
`
|
|
1312
1410
|
}
|
|
1313
1411
|
];
|
|
1314
1412
|
|
|
1315
1413
|
// src/db/open.ts
|
|
1316
1414
|
var DB_HINT = "Check the path in --db / OURA_DB_PATH and that the file is a SQLite database oura-cli created.";
|
|
1317
1415
|
var BUSY_HINT = "Another oura-cli process is using this database; wait for it to finish and retry.";
|
|
1416
|
+
var PERMISSION_HINT = "Check that you can write both the file and the directory holding it \u2014 SQLite creates -wal and -shm files alongside the database.";
|
|
1318
1417
|
var BUSY_TIMEOUT_MS = 5000;
|
|
1418
|
+
var WAL_SWITCH_ATTEMPTS = 20;
|
|
1419
|
+
var WAL_SWITCH_WAIT_MS = 25;
|
|
1420
|
+
function hintFor(detail) {
|
|
1421
|
+
if (/database is locked|SQLITE_BUSY/i.test(detail))
|
|
1422
|
+
return BUSY_HINT;
|
|
1423
|
+
if (/readonly database|read-only|EACCES|permission denied|unable to open database file/i.test(detail))
|
|
1424
|
+
return PERMISSION_HINT;
|
|
1425
|
+
return DB_HINT;
|
|
1426
|
+
}
|
|
1319
1427
|
function dbError(what, err) {
|
|
1320
1428
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1321
|
-
|
|
1322
|
-
return new CliError("DB_ERROR", `${what}: ${detail}`, hint);
|
|
1429
|
+
return new CliError("DB_ERROR", `${what}: ${detail}`, hintFor(detail));
|
|
1323
1430
|
}
|
|
1324
1431
|
function asDbError(err) {
|
|
1325
1432
|
return err instanceof SQLiteError ? dbError("Database query failed", err) : undefined;
|
|
1326
1433
|
}
|
|
1327
1434
|
function getDbPath(explicit) {
|
|
1328
|
-
if (explicit)
|
|
1329
|
-
return explicit;
|
|
1330
|
-
|
|
1331
|
-
|
|
1435
|
+
if (explicit !== undefined)
|
|
1436
|
+
return requireValue(explicit, "--db", "the default database");
|
|
1437
|
+
const fromEnv = process.env.OURA_DB_PATH;
|
|
1438
|
+
if (fromEnv !== undefined)
|
|
1439
|
+
return requireValue(fromEnv, "OURA_DB_PATH", "the default database");
|
|
1332
1440
|
return resolve(homedir(), ".oura-cli", "oura.db");
|
|
1333
1441
|
}
|
|
1334
1442
|
function openDatabase(explicit) {
|
|
@@ -1342,15 +1450,31 @@ function openDatabase(explicit) {
|
|
|
1342
1450
|
if (isNew)
|
|
1343
1451
|
chmodSync(dbPath, 384);
|
|
1344
1452
|
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
1453
|
+
if (journalMode(db) !== "wal")
|
|
1454
|
+
enableWal(db);
|
|
1348
1455
|
db.exec("PRAGMA foreign_keys = ON");
|
|
1349
1456
|
return db;
|
|
1350
1457
|
} catch (err) {
|
|
1351
1458
|
throw dbError(`Cannot open database ${dbPath}`, err);
|
|
1352
1459
|
}
|
|
1353
1460
|
}
|
|
1461
|
+
function journalMode(db) {
|
|
1462
|
+
return db.query("PRAGMA journal_mode").get().journal_mode;
|
|
1463
|
+
}
|
|
1464
|
+
function enableWal(db) {
|
|
1465
|
+
for (let attempt = 0;; attempt++) {
|
|
1466
|
+
try {
|
|
1467
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
1468
|
+
return;
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
if (journalMode(db) === "wal")
|
|
1471
|
+
return;
|
|
1472
|
+
if (attempt >= WAL_SWITCH_ATTEMPTS)
|
|
1473
|
+
throw err;
|
|
1474
|
+
Bun.sleepSync(WAL_SWITCH_WAIT_MS + Math.random() * WAL_SWITCH_WAIT_MS);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1354
1478
|
function schemaVersion(db) {
|
|
1355
1479
|
db.exec("CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)");
|
|
1356
1480
|
const row = db.query("SELECT MAX(version) AS v FROM _schema_version").get();
|
|
@@ -1358,12 +1482,23 @@ function schemaVersion(db) {
|
|
|
1358
1482
|
}
|
|
1359
1483
|
function ensureSchema(db, migrations = MIGRATIONS) {
|
|
1360
1484
|
try {
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1485
|
+
if (schemaVersion(db) >= Math.max(0, ...migrations.map((m) => m.version)))
|
|
1486
|
+
return;
|
|
1487
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1488
|
+
try {
|
|
1489
|
+
const current = schemaVersion(db);
|
|
1490
|
+
for (const m of migrations) {
|
|
1491
|
+
if (m.version > current) {
|
|
1492
|
+
db.exec(m.sql);
|
|
1493
|
+
db.query("INSERT INTO _schema_version (version) VALUES (?)").run(m.version);
|
|
1494
|
+
}
|
|
1366
1495
|
}
|
|
1496
|
+
db.exec("COMMIT");
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
try {
|
|
1499
|
+
db.exec("ROLLBACK");
|
|
1500
|
+
} catch {}
|
|
1501
|
+
throw err;
|
|
1367
1502
|
}
|
|
1368
1503
|
} catch (err) {
|
|
1369
1504
|
throw dbError("Schema migration failed", err);
|
|
@@ -1378,10 +1513,11 @@ function defaultTokenPath() {
|
|
|
1378
1513
|
return process.env.OURA_TOKEN_PATH ?? resolve2(homedir2(), ".oura-token");
|
|
1379
1514
|
}
|
|
1380
1515
|
function resolveToken(explicit, tokenPath) {
|
|
1381
|
-
if (explicit)
|
|
1382
|
-
return { token: explicit.trim(), source: "--token" };
|
|
1383
|
-
|
|
1384
|
-
|
|
1516
|
+
if (explicit !== undefined)
|
|
1517
|
+
return { token: requireValue(explicit, "--token", "the token file").trim(), source: "--token" };
|
|
1518
|
+
const fromEnv = process.env.OURA_TOKEN;
|
|
1519
|
+
if (fromEnv !== undefined)
|
|
1520
|
+
return { token: requireValue(fromEnv, "OURA_TOKEN", "the token file").trim(), source: "OURA_TOKEN" };
|
|
1385
1521
|
const path = tokenPath ?? defaultTokenPath();
|
|
1386
1522
|
try {
|
|
1387
1523
|
return { token: readFileSync(path, "utf-8").trim(), source: path };
|
|
@@ -1469,15 +1605,13 @@ var SUBCOMMANDS = new Set([
|
|
|
1469
1605
|
]);
|
|
1470
1606
|
function normalizeArgv(argv) {
|
|
1471
1607
|
const [bun, script, ...rest] = argv;
|
|
1472
|
-
const
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
const
|
|
1476
|
-
const subcommandOnwards = rest.slice(subIdx);
|
|
1608
|
+
const dd = rest.indexOf("--");
|
|
1609
|
+
const scanned = dd >= 0 ? rest.slice(0, dd) : rest;
|
|
1610
|
+
const passthrough = dd >= 0 ? rest.slice(dd) : [];
|
|
1611
|
+
const kept = [];
|
|
1477
1612
|
const hoisted = [];
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
const tok = before[i];
|
|
1613
|
+
for (let i = 0;i < scanned.length; i++) {
|
|
1614
|
+
const tok = scanned[i];
|
|
1481
1615
|
if (tok.includes("=")) {
|
|
1482
1616
|
const name = tok.slice(0, tok.indexOf("="));
|
|
1483
1617
|
if (GLOBAL_FLAGS_WITH_VALUE.has(name) || GLOBAL_FLAGS_BOOLEAN.has(name)) {
|
|
@@ -1487,8 +1621,8 @@ function normalizeArgv(argv) {
|
|
|
1487
1621
|
}
|
|
1488
1622
|
if (GLOBAL_FLAGS_WITH_VALUE.has(tok)) {
|
|
1489
1623
|
hoisted.push(tok);
|
|
1490
|
-
if (i + 1 <
|
|
1491
|
-
hoisted.push(
|
|
1624
|
+
if (i + 1 < scanned.length) {
|
|
1625
|
+
hoisted.push(scanned[i + 1]);
|
|
1492
1626
|
i++;
|
|
1493
1627
|
}
|
|
1494
1628
|
continue;
|
|
@@ -1497,13 +1631,9 @@ function normalizeArgv(argv) {
|
|
|
1497
1631
|
hoisted.push(tok);
|
|
1498
1632
|
continue;
|
|
1499
1633
|
}
|
|
1500
|
-
|
|
1634
|
+
kept.push(tok);
|
|
1501
1635
|
}
|
|
1502
|
-
|
|
1503
|
-
if (dd >= 0) {
|
|
1504
|
-
return [bun, script, ...leftover, ...subcommandOnwards.slice(0, dd), ...hoisted, ...subcommandOnwards.slice(dd)];
|
|
1505
|
-
}
|
|
1506
|
-
return [bun, script, ...leftover, ...subcommandOnwards, ...hoisted];
|
|
1636
|
+
return [bun, script, ...kept, ...hoisted, ...passthrough];
|
|
1507
1637
|
}
|
|
1508
1638
|
function isVersionRequest(rawArgs) {
|
|
1509
1639
|
for (let i = 0;i < rawArgs.length; i++) {
|
|
@@ -1521,13 +1651,15 @@ function isVersionRequest(rawArgs) {
|
|
|
1521
1651
|
}
|
|
1522
1652
|
|
|
1523
1653
|
// src/lib/format-resolve.ts
|
|
1524
|
-
function
|
|
1525
|
-
if (explicit === undefined)
|
|
1526
|
-
return
|
|
1527
|
-
if (explicit === "table" || explicit === "json")
|
|
1528
|
-
return explicit;
|
|
1654
|
+
function assertValidFormat(explicit) {
|
|
1655
|
+
if (explicit === undefined || explicit === "table" || explicit === "json")
|
|
1656
|
+
return;
|
|
1529
1657
|
throw new CliError("BAD_ARGS", `Unknown --format value: "${explicit}". Use "table" or "json".`);
|
|
1530
1658
|
}
|
|
1659
|
+
function resolveFormat({ explicit, isTty }) {
|
|
1660
|
+
assertValidFormat(explicit);
|
|
1661
|
+
return explicit ?? (isTty ? "table" : "json");
|
|
1662
|
+
}
|
|
1531
1663
|
function formatFromArgv(argv, isTty) {
|
|
1532
1664
|
let explicit;
|
|
1533
1665
|
for (let i = 0;i < argv.length; i++) {
|
|
@@ -1592,8 +1724,9 @@ function localDateToUtcRange(localDate, timezone) {
|
|
|
1592
1724
|
return [iso(localMidnightMs(localDate, timezone)), iso(localMidnightMs(shiftDay(localDate, 1), timezone))];
|
|
1593
1725
|
}
|
|
1594
1726
|
function resolveDefaultTimezone() {
|
|
1595
|
-
|
|
1596
|
-
|
|
1727
|
+
const fromEnv = process.env.OURA_TZ;
|
|
1728
|
+
if (fromEnv !== undefined)
|
|
1729
|
+
return requireValue(fromEnv, "OURA_TZ", "the system timezone");
|
|
1597
1730
|
try {
|
|
1598
1731
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
1599
1732
|
} catch {
|
|
@@ -1659,6 +1792,8 @@ var processIo = {
|
|
|
1659
1792
|
};
|
|
1660
1793
|
var camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
1661
1794
|
function assertKnownArgs(declared, args) {
|
|
1795
|
+
if ("format" in declared)
|
|
1796
|
+
assertValidFormat(args.format);
|
|
1662
1797
|
const known = new Set(["_"]);
|
|
1663
1798
|
let positionals = 0;
|
|
1664
1799
|
for (const [name, def] of Object.entries(declared)) {
|
|
@@ -1891,6 +2026,7 @@ var hr = defineCollection({
|
|
|
1891
2026
|
conflict: "ignore",
|
|
1892
2027
|
rangeParams: "datetime",
|
|
1893
2028
|
maxRangeDays: 30,
|
|
2029
|
+
syncLookbackDays: 14,
|
|
1894
2030
|
identity: [{ field: "timestamp", format: "date-time", description: "ISO 8601 timestamp of the sample" }],
|
|
1895
2031
|
columns: [
|
|
1896
2032
|
{ name: "timestamp", type: "TEXT", pick: (r) => r.timestamp },
|
|
@@ -2028,6 +2164,192 @@ var cvAge = defineCollection({
|
|
|
2028
2164
|
]
|
|
2029
2165
|
});
|
|
2030
2166
|
|
|
2167
|
+
// src/collections/resilience.ts
|
|
2168
|
+
var resilience = defineCollection({
|
|
2169
|
+
name: "resilience",
|
|
2170
|
+
endpoint: "daily_resilience",
|
|
2171
|
+
table: "daily_resilience",
|
|
2172
|
+
description: "Daily resilience level with its sleep-recovery, daytime-recovery and stress contributors",
|
|
2173
|
+
conflict: "replace",
|
|
2174
|
+
rangeParams: "date",
|
|
2175
|
+
identity: [
|
|
2176
|
+
{ field: "id", description: "Oura record id" },
|
|
2177
|
+
{ field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
|
|
2178
|
+
],
|
|
2179
|
+
columns: [
|
|
2180
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2181
|
+
{ name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
|
|
2182
|
+
{ name: "level", type: "TEXT", pick: (r) => r.level ?? null },
|
|
2183
|
+
{ name: "sleep_recovery", type: "REAL", pick: (r) => r.contributors?.sleep_recovery ?? null },
|
|
2184
|
+
{ name: "daytime_recovery", type: "REAL", pick: (r) => r.contributors?.daytime_recovery ?? null },
|
|
2185
|
+
{ name: "stress", type: "REAL", pick: (r) => r.contributors?.stress ?? null }
|
|
2186
|
+
]
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
// src/collections/vo2max.ts
|
|
2190
|
+
var vo2max = defineCollection({
|
|
2191
|
+
name: "vo2max",
|
|
2192
|
+
endpoint: "vO2_max",
|
|
2193
|
+
table: "vo2max",
|
|
2194
|
+
description: "Daily VO2 max estimate",
|
|
2195
|
+
conflict: "replace",
|
|
2196
|
+
rangeParams: "date",
|
|
2197
|
+
dayRangeOffset: [0, 1],
|
|
2198
|
+
identity: [
|
|
2199
|
+
{ field: "id", description: "Oura record id" },
|
|
2200
|
+
{ field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
|
|
2201
|
+
],
|
|
2202
|
+
columns: [
|
|
2203
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2204
|
+
{ name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
|
|
2205
|
+
{ name: "vo2_max", type: "REAL", pick: (r) => r.vo2_max ?? null },
|
|
2206
|
+
{ name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
|
|
2207
|
+
]
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
// src/collections/sleep-time.ts
|
|
2211
|
+
var sleepTime = defineCollection({
|
|
2212
|
+
name: "sleep-time",
|
|
2213
|
+
endpoint: "sleep_time",
|
|
2214
|
+
table: "sleep_time",
|
|
2215
|
+
description: "Suggested bedtime window (offsets in seconds from midnight) with status and recommendation",
|
|
2216
|
+
conflict: "replace",
|
|
2217
|
+
rangeParams: "date",
|
|
2218
|
+
identity: [
|
|
2219
|
+
{ field: "id", description: "Oura record id" },
|
|
2220
|
+
{ field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
|
|
2221
|
+
],
|
|
2222
|
+
columns: [
|
|
2223
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2224
|
+
{ name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
|
|
2225
|
+
{ name: "status", type: "TEXT", pick: (r) => r.status ?? null },
|
|
2226
|
+
{ name: "recommendation", type: "TEXT", pick: (r) => r.recommendation ?? null },
|
|
2227
|
+
{ name: "bedtime_start_offset", type: "INTEGER", pick: (r) => r.optimal_bedtime?.start_offset ?? null },
|
|
2228
|
+
{ name: "bedtime_end_offset", type: "INTEGER", pick: (r) => r.optimal_bedtime?.end_offset ?? null },
|
|
2229
|
+
{ name: "day_tz", type: "INTEGER", pick: (r) => r.optimal_bedtime?.day_tz ?? null }
|
|
2230
|
+
]
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
// src/collections/session.ts
|
|
2234
|
+
var session = defineCollection({
|
|
2235
|
+
name: "session",
|
|
2236
|
+
endpoint: "session",
|
|
2237
|
+
table: "sessions",
|
|
2238
|
+
description: "Guided sessions (meditation, breathing, naps, rest) with their sample series as JSON",
|
|
2239
|
+
conflict: "replace",
|
|
2240
|
+
rangeParams: "date",
|
|
2241
|
+
dayRangeOffset: [0, 1],
|
|
2242
|
+
identity: [
|
|
2243
|
+
{ field: "id", description: "Oura record id" },
|
|
2244
|
+
{ field: "day", format: "date", description: "Date the session belongs to (YYYY-MM-DD)" }
|
|
2245
|
+
],
|
|
2246
|
+
columns: [
|
|
2247
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2248
|
+
{ name: "day", type: "TEXT", pick: (r) => r.day },
|
|
2249
|
+
{ name: "type", type: "TEXT", pick: (r) => r.type ?? null },
|
|
2250
|
+
{ name: "mood", type: "TEXT", pick: (r) => r.mood ?? null },
|
|
2251
|
+
{ name: "start_datetime", type: "TEXT", pick: (r) => r.start_datetime },
|
|
2252
|
+
{ name: "end_datetime", type: "TEXT", pick: (r) => r.end_datetime },
|
|
2253
|
+
{ name: "heart_rate", type: "TEXT", pick: (r) => r.heart_rate == null ? null : JSON.stringify(r.heart_rate) },
|
|
2254
|
+
{ name: "heart_rate_variability", type: "TEXT", pick: (r) => r.heart_rate_variability == null ? null : JSON.stringify(r.heart_rate_variability) },
|
|
2255
|
+
{ name: "motion_count", type: "TEXT", pick: (r) => r.motion_count == null ? null : JSON.stringify(r.motion_count) }
|
|
2256
|
+
],
|
|
2257
|
+
indexes: [{ name: "idx_sessions_day", columns: ["day"] }]
|
|
2258
|
+
});
|
|
2259
|
+
|
|
2260
|
+
// src/collections/rest-mode.ts
|
|
2261
|
+
var restMode = defineCollection({
|
|
2262
|
+
name: "rest-mode",
|
|
2263
|
+
endpoint: "rest_mode_period",
|
|
2264
|
+
table: "rest_mode_periods",
|
|
2265
|
+
description: "Rest mode periods; `day` is the period start day, episodes are kept as JSON",
|
|
2266
|
+
conflict: "replace",
|
|
2267
|
+
rangeParams: "date",
|
|
2268
|
+
dayRangeOffset: [0, 1],
|
|
2269
|
+
identity: [
|
|
2270
|
+
{ field: "id", description: "Oura record id" },
|
|
2271
|
+
{ field: "start_day", format: "date", description: "First day of the period (YYYY-MM-DD)" }
|
|
2272
|
+
],
|
|
2273
|
+
columns: [
|
|
2274
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2275
|
+
{ name: "day", type: "TEXT", pick: (r) => r.start_day },
|
|
2276
|
+
{ name: "end_day", type: "TEXT", pick: (r) => r.end_day ?? null },
|
|
2277
|
+
{ name: "start_time", type: "TEXT", pick: (r) => r.start_time ?? null },
|
|
2278
|
+
{ name: "end_time", type: "TEXT", pick: (r) => r.end_time ?? null },
|
|
2279
|
+
{ name: "episodes", type: "TEXT", pick: (r) => r.episodes == null ? null : JSON.stringify(r.episodes) }
|
|
2280
|
+
],
|
|
2281
|
+
indexes: [{ name: "idx_rest_mode_periods_day", columns: ["day"] }]
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
// src/collections/tags.ts
|
|
2285
|
+
var tags = defineCollection({
|
|
2286
|
+
name: "tags",
|
|
2287
|
+
endpoint: "enhanced_tag",
|
|
2288
|
+
table: "enhanced_tags",
|
|
2289
|
+
description: "Tags the user added in the app; `day` is the tag start day",
|
|
2290
|
+
conflict: "replace",
|
|
2291
|
+
rangeParams: "date",
|
|
2292
|
+
dayRangeOffset: [0, 1],
|
|
2293
|
+
identity: [
|
|
2294
|
+
{ field: "id", description: "Oura record id" },
|
|
2295
|
+
{ field: "start_day", format: "date", description: "Day the tag starts on (YYYY-MM-DD)" }
|
|
2296
|
+
],
|
|
2297
|
+
columns: [
|
|
2298
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2299
|
+
{ name: "day", type: "TEXT", pick: (r) => r.start_day },
|
|
2300
|
+
{ name: "end_day", type: "TEXT", pick: (r) => r.end_day ?? null },
|
|
2301
|
+
{ name: "start_time", type: "TEXT", pick: (r) => r.start_time ?? null },
|
|
2302
|
+
{ name: "end_time", type: "TEXT", pick: (r) => r.end_time ?? null },
|
|
2303
|
+
{ name: "tag_type_code", type: "TEXT", pick: (r) => r.tag_type_code ?? null },
|
|
2304
|
+
{ name: "comment", type: "TEXT", pick: (r) => r.comment ?? null },
|
|
2305
|
+
{ name: "custom_name", type: "TEXT", pick: (r) => r.custom_name ?? null }
|
|
2306
|
+
],
|
|
2307
|
+
indexes: [{ name: "idx_enhanced_tags_day", columns: ["day"] }]
|
|
2308
|
+
});
|
|
2309
|
+
|
|
2310
|
+
// src/collections/ring.ts
|
|
2311
|
+
var ring = defineCollection({
|
|
2312
|
+
name: "ring",
|
|
2313
|
+
endpoint: "ring_configuration",
|
|
2314
|
+
table: "ring_configuration",
|
|
2315
|
+
description: "Ring hardware, colour, size, firmware and set-up time; a snapshot list, not a day range",
|
|
2316
|
+
conflict: "replace",
|
|
2317
|
+
rangeParams: "none",
|
|
2318
|
+
identity: [{ field: "id", description: "Oura ring id" }],
|
|
2319
|
+
columns: [
|
|
2320
|
+
{ name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
|
|
2321
|
+
{ name: "color", type: "TEXT", pick: (r) => r.color ?? null },
|
|
2322
|
+
{ name: "design", type: "TEXT", pick: (r) => r.design ?? null },
|
|
2323
|
+
{ name: "firmware_version", type: "TEXT", pick: (r) => r.firmware_version ?? null },
|
|
2324
|
+
{ name: "hardware_type", type: "TEXT", pick: (r) => r.hardware_type ?? null },
|
|
2325
|
+
{ name: "set_up_at", type: "TEXT", pick: (r) => r.set_up_at ?? null },
|
|
2326
|
+
{ name: "size", type: "INTEGER", pick: (r) => r.size ?? null }
|
|
2327
|
+
]
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
// src/collections/battery.ts
|
|
2331
|
+
var battery = defineCollection({
|
|
2332
|
+
name: "battery",
|
|
2333
|
+
endpoint: "ring_battery_level",
|
|
2334
|
+
table: "ring_battery_level",
|
|
2335
|
+
description: "Ring battery level events (percent) with charging state",
|
|
2336
|
+
conflict: "ignore",
|
|
2337
|
+
rangeParams: "datetime",
|
|
2338
|
+
maxRangeDays: 30,
|
|
2339
|
+
identity: [{ field: "timestamp", format: "date-time", description: "ISO 8601 timestamp of the event" }],
|
|
2340
|
+
columns: [
|
|
2341
|
+
{ name: "timestamp", type: "TEXT", pick: (r) => r.timestamp },
|
|
2342
|
+
{ name: "level", type: "INTEGER", pick: (r) => r.level ?? null },
|
|
2343
|
+
{ name: "charging", type: "INTEGER", pick: (r) => r.charging == null ? null : r.charging ? 1 : 0 },
|
|
2344
|
+
{ name: "in_charger", type: "INTEGER", pick: (r) => r.in_charger == null ? null : r.in_charger ? 1 : 0 },
|
|
2345
|
+
{ name: "day", type: "TEXT", pick: (r) => r.timestamp.slice(0, 10) }
|
|
2346
|
+
],
|
|
2347
|
+
indexes: [
|
|
2348
|
+
{ name: "idx_ring_battery_level_unique", columns: ["timestamp"], unique: true },
|
|
2349
|
+
{ name: "idx_ring_battery_level_day", columns: ["day"] }
|
|
2350
|
+
]
|
|
2351
|
+
});
|
|
2352
|
+
|
|
2031
2353
|
// src/collections/index.ts
|
|
2032
2354
|
var COLLECTIONS = [
|
|
2033
2355
|
sleep,
|
|
@@ -2038,7 +2360,15 @@ var COLLECTIONS = [
|
|
|
2038
2360
|
stress,
|
|
2039
2361
|
workout,
|
|
2040
2362
|
sleepPeriods,
|
|
2041
|
-
cvAge
|
|
2363
|
+
cvAge,
|
|
2364
|
+
resilience,
|
|
2365
|
+
vo2max,
|
|
2366
|
+
sleepTime,
|
|
2367
|
+
session,
|
|
2368
|
+
restMode,
|
|
2369
|
+
tags,
|
|
2370
|
+
ring,
|
|
2371
|
+
battery
|
|
2042
2372
|
];
|
|
2043
2373
|
function names() {
|
|
2044
2374
|
return COLLECTIONS.map((c) => c.name);
|
|
@@ -2078,17 +2408,21 @@ function datetimeQueries(start, end, tz, maxDays) {
|
|
|
2078
2408
|
return out;
|
|
2079
2409
|
}
|
|
2080
2410
|
function rangeQueries(c, start, end, tz) {
|
|
2411
|
+
if (c.rangeParams === "none")
|
|
2412
|
+
return [{}];
|
|
2081
2413
|
if (start > end)
|
|
2082
2414
|
return [];
|
|
2083
2415
|
return c.rangeParams === "date" ? dateQueries(start, end, c.maxRangeDays, c.dayRangeOffset ?? [0, 0]) : datetimeQueries(start, end, tz, c.maxRangeDays);
|
|
2084
2416
|
}
|
|
2085
|
-
async function
|
|
2086
|
-
const
|
|
2417
|
+
async function fetchCollectionByPiece(client, c, start, end, tz) {
|
|
2418
|
+
const pieces = [];
|
|
2087
2419
|
for (const query of rangeQueries(c, start, end, tz)) {
|
|
2088
|
-
|
|
2089
|
-
rows.push(row);
|
|
2420
|
+
pieces.push(await client.fetch(c.endpoint, query));
|
|
2090
2421
|
}
|
|
2091
|
-
return
|
|
2422
|
+
return pieces;
|
|
2423
|
+
}
|
|
2424
|
+
async function fetchCollection(client, c, start, end, tz) {
|
|
2425
|
+
return (await fetchCollectionByPiece(client, c, start, end, tz)).flat();
|
|
2092
2426
|
}
|
|
2093
2427
|
|
|
2094
2428
|
// src/commands/describe.ts
|
|
@@ -2190,43 +2524,191 @@ function describeCommand(version, getCommands) {
|
|
|
2190
2524
|
});
|
|
2191
2525
|
}
|
|
2192
2526
|
|
|
2527
|
+
// src/db/reconcile.ts
|
|
2528
|
+
function identityColumns(c) {
|
|
2529
|
+
const unique = c.columns.filter((col) => col.unique).map((col) => col.name);
|
|
2530
|
+
if (unique.length > 0)
|
|
2531
|
+
return unique;
|
|
2532
|
+
const pk = c.columns.filter((col) => col.pk).map((col) => col.name);
|
|
2533
|
+
if (pk.length > 0)
|
|
2534
|
+
return pk;
|
|
2535
|
+
return (c.indexes ?? []).find((i) => i.unique)?.columns ?? [];
|
|
2536
|
+
}
|
|
2537
|
+
var MAX_REMOVED_SHARE = 0.5;
|
|
2538
|
+
var ALWAYS_SAFE_TO_REMOVE = 5;
|
|
2539
|
+
var KEY_SEPARATOR = "\x00";
|
|
2540
|
+
function emptyPlan() {
|
|
2541
|
+
return { added: 0, stale: [], refused: 0, bypassed: 0 };
|
|
2542
|
+
}
|
|
2543
|
+
function keyOf(values) {
|
|
2544
|
+
return values.map((v) => String(v)).join(KEY_SEPARATOR);
|
|
2545
|
+
}
|
|
2546
|
+
function planWindow(db, c, pieces, options = {}) {
|
|
2547
|
+
const identity = identityColumns(c);
|
|
2548
|
+
if (identity.length === 0)
|
|
2549
|
+
return emptyPlan();
|
|
2550
|
+
const pickOf = (name) => c.columns.find((col) => col.name === name)?.pick;
|
|
2551
|
+
const identityPicks = identity.map(pickOf);
|
|
2552
|
+
const scopeName = c.rangeParams === "datetime" ? "timestamp" : "day";
|
|
2553
|
+
const scopePick = pickOf(scopeName);
|
|
2554
|
+
if (scopePick === undefined || identityPicks.some((p) => p === undefined))
|
|
2555
|
+
return emptyPlan();
|
|
2556
|
+
const keyFor = (row) => keyOf(identityPicks.map((pick) => pick(row)));
|
|
2557
|
+
const wanted = new Set(pieces.flat().map(keyFor));
|
|
2558
|
+
const plan = emptyPlan();
|
|
2559
|
+
const judged = pieces.map((piece) => {
|
|
2560
|
+
if (piece.length === 0)
|
|
2561
|
+
return null;
|
|
2562
|
+
const scopeValues = piece.map((row) => scopePick(row)).filter((v) => v !== null && v !== undefined).map(String);
|
|
2563
|
+
if (scopeValues.length === 0)
|
|
2564
|
+
return null;
|
|
2565
|
+
const days = [...new Set(scopeValues)];
|
|
2566
|
+
const [where, params] = c.rangeParams === "datetime" ? [`${scopeName} BETWEEN ? AND ?`, [minOf(scopeValues), maxOf(scopeValues)]] : [`${scopeName} IN (${days.map(() => "?").join(", ")})`, days];
|
|
2567
|
+
const stored = db.query(`SELECT ${identity.join(", ")} FROM ${c.table} WHERE ${where}`).all(...params);
|
|
2568
|
+
const storedKeys = new Set;
|
|
2569
|
+
const stale = [];
|
|
2570
|
+
for (const row of stored) {
|
|
2571
|
+
const values = identity.map((name) => row[name] ?? null);
|
|
2572
|
+
const key = keyOf(values);
|
|
2573
|
+
storedKeys.add(key);
|
|
2574
|
+
if (!wanted.has(key))
|
|
2575
|
+
stale.push(values);
|
|
2576
|
+
}
|
|
2577
|
+
const looksTruncated = stale.length > ALWAYS_SAFE_TO_REMOVE && stale.length > stored.length * MAX_REMOVED_SHARE;
|
|
2578
|
+
const fresh = [...new Set(piece.map(keyFor))].filter((key) => !storedKeys.has(key));
|
|
2579
|
+
return { stale, looksTruncated, fresh };
|
|
2580
|
+
});
|
|
2581
|
+
const doubted = new Set;
|
|
2582
|
+
for (const p of judged) {
|
|
2583
|
+
if (p?.looksTruncated)
|
|
2584
|
+
for (const values of p.stale)
|
|
2585
|
+
doubted.add(keyOf(values));
|
|
2586
|
+
}
|
|
2587
|
+
const countedNew = new Set;
|
|
2588
|
+
const countedStale = new Set;
|
|
2589
|
+
for (const p of judged) {
|
|
2590
|
+
if (p === null)
|
|
2591
|
+
continue;
|
|
2592
|
+
for (const key of p.fresh) {
|
|
2593
|
+
if (countedNew.has(key))
|
|
2594
|
+
continue;
|
|
2595
|
+
countedNew.add(key);
|
|
2596
|
+
plan.added += 1;
|
|
2597
|
+
}
|
|
2598
|
+
for (const values of p.stale) {
|
|
2599
|
+
const key = keyOf(values);
|
|
2600
|
+
if (countedStale.has(key))
|
|
2601
|
+
continue;
|
|
2602
|
+
countedStale.add(key);
|
|
2603
|
+
if (doubted.has(key) && !options.prune) {
|
|
2604
|
+
plan.refused += 1;
|
|
2605
|
+
} else {
|
|
2606
|
+
if (doubted.has(key))
|
|
2607
|
+
plan.bypassed += 1;
|
|
2608
|
+
plan.stale.push(values);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
return plan;
|
|
2613
|
+
}
|
|
2614
|
+
function applyWindowPlan(db, c, plan) {
|
|
2615
|
+
if (plan.stale.length === 0)
|
|
2616
|
+
return 0;
|
|
2617
|
+
const identity = identityColumns(c);
|
|
2618
|
+
const del = db.query(`DELETE FROM ${c.table} WHERE ${identity.map((name) => `${name} IS ?`).join(" AND ")}`);
|
|
2619
|
+
let removed = 0;
|
|
2620
|
+
for (const values of plan.stale)
|
|
2621
|
+
removed += del.run(...values).changes;
|
|
2622
|
+
return removed;
|
|
2623
|
+
}
|
|
2624
|
+
function minOf(values) {
|
|
2625
|
+
return values.reduce((a, b) => b < a ? b : a);
|
|
2626
|
+
}
|
|
2627
|
+
function maxOf(values) {
|
|
2628
|
+
return values.reduce((a, b) => b > a ? b : a);
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2193
2631
|
// src/db/sync.ts
|
|
2194
2632
|
var BACKFILL_DAYS = 30;
|
|
2195
|
-
function lastDay(db, table) {
|
|
2196
|
-
return db.query(`SELECT MAX(day) AS d FROM ${table}
|
|
2633
|
+
function lastDay(db, table, end) {
|
|
2634
|
+
return db.query(`SELECT MAX(day) AS d FROM ${table} WHERE day <= ?`).get(end).d;
|
|
2197
2635
|
}
|
|
2198
|
-
function
|
|
2199
|
-
return db.query(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
|
|
2200
|
-
}
|
|
2201
|
-
async function importDaily(db, client, clock, log, window = {}) {
|
|
2636
|
+
async function importDaily(db, client, clock, log, window = {}, options = {}) {
|
|
2202
2637
|
const { today, tz } = clock;
|
|
2203
2638
|
const _log = log ?? (() => {});
|
|
2204
2639
|
const end = window.to ?? today;
|
|
2205
2640
|
const backfillStart = shiftDay(end, -(BACKFILL_DAYS - 1));
|
|
2206
2641
|
const plan = COLLECTIONS.map((c) => {
|
|
2207
|
-
const last = lastDay(db, c.table);
|
|
2208
|
-
|
|
2642
|
+
const last = c.rangeParams === "none" ? null : lastDay(db, c.table, end);
|
|
2643
|
+
const resume = window.from ?? last ?? backfillStart;
|
|
2644
|
+
const start = window.from ?? shiftDay(resume, -(last === null ? 0 : c.syncLookbackDays ?? 0));
|
|
2645
|
+
return { c, last, resume, start };
|
|
2209
2646
|
});
|
|
2210
|
-
const
|
|
2211
|
-
const
|
|
2647
|
+
const ranged = plan.filter((p) => p.c.rangeParams !== "none");
|
|
2648
|
+
const isFirstSync = ranged.every((p) => p.last === null);
|
|
2649
|
+
const startDate = ranged.map((p) => p.resume).sort()[0];
|
|
2212
2650
|
_log(isFirstSync && window.from === undefined ? `First sync \u2014 backfilling the last ${BACKFILL_DAYS} days: ${startDate} \u2192 ${end}` : `Syncing ${startDate} \u2192 ${end}`);
|
|
2651
|
+
if (options.prune !== undefined) {
|
|
2652
|
+
const scope = options.prune === "all" ? "every collection" : options.prune.join(", ");
|
|
2653
|
+
_log(`--prune: applying removals even where a response looks truncated (${scope})`);
|
|
2654
|
+
}
|
|
2213
2655
|
const fetched = {};
|
|
2214
2656
|
const added = {};
|
|
2657
|
+
const removed = {};
|
|
2658
|
+
const refused = {};
|
|
2659
|
+
const pruned = {};
|
|
2660
|
+
const mayPrune = (name) => options.prune === "all" || (options.prune?.includes(name) ?? false);
|
|
2215
2661
|
for (const { c, start } of plan) {
|
|
2216
|
-
const
|
|
2217
|
-
const
|
|
2662
|
+
const pieces = await fetchCollectionByPiece(client, c, start, end, tz);
|
|
2663
|
+
const rows = pieces.flat();
|
|
2218
2664
|
const stmt = db.query(insertSql(c));
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2665
|
+
if (c.rangeParams === "none") {
|
|
2666
|
+
const pk = c.columns.find((col) => col.pk)?.name;
|
|
2667
|
+
if (!pk)
|
|
2668
|
+
throw new Error(`Snapshot collection ${c.name} must declare a primary-key column (enforced by the registry tests).`);
|
|
2669
|
+
const ids = () => new Set(db.query(`SELECT ${pk} AS id FROM ${c.table}`).all().map((r) => r.id));
|
|
2670
|
+
const known = ids();
|
|
2671
|
+
db.transaction((rs) => {
|
|
2672
|
+
db.exec(`DELETE FROM ${c.table}`);
|
|
2673
|
+
for (const r of rs)
|
|
2674
|
+
stmt.run(...rowValues(c, r));
|
|
2675
|
+
})(rows);
|
|
2676
|
+
fetched[c.table] = rows.length;
|
|
2677
|
+
added[c.table] = [...ids()].filter((id) => !known.has(id)).length;
|
|
2678
|
+
_log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new${rows.length === 0 ? ", table cleared" : ""}`);
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
const { windowPlan, gone } = db.transaction((ps) => {
|
|
2682
|
+
const windowPlan = planWindow(db, c, ps, { prune: mayPrune(c.name) });
|
|
2683
|
+
for (const piece of ps)
|
|
2684
|
+
for (const r of piece)
|
|
2685
|
+
stmt.run(...rowValues(c, r));
|
|
2686
|
+
return { windowPlan, gone: applyWindowPlan(db, c, windowPlan) };
|
|
2687
|
+
}).immediate(pieces);
|
|
2223
2688
|
fetched[c.table] = rows.length;
|
|
2224
|
-
added[c.table] =
|
|
2225
|
-
if (
|
|
2226
|
-
|
|
2689
|
+
added[c.table] = windowPlan.added;
|
|
2690
|
+
if (gone > 0)
|
|
2691
|
+
removed[c.table] = gone;
|
|
2692
|
+
if (windowPlan.refused > 0)
|
|
2693
|
+
refused[c.table] = { rows: windowPlan.refused, collection: c.name };
|
|
2694
|
+
if (windowPlan.bypassed > 0)
|
|
2695
|
+
pruned[c.table] = { rows: windowPlan.bypassed, collection: c.name };
|
|
2696
|
+
const tail = gone > 0 ? `, ${gone} stale removed${windowPlan.bypassed > 0 ? ` (${windowPlan.bypassed} past the truncation guard)` : ""}` : "";
|
|
2697
|
+
const kept = windowPlan.refused > 0 ? `, ${windowPlan.refused} rows kept that the API did not return \u2014 too many to drop on one response; re-run with --prune=${c.name} to apply them` : "";
|
|
2698
|
+
_log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new${tail}${kept}`);
|
|
2227
2699
|
}
|
|
2228
2700
|
_log("Import complete.");
|
|
2229
|
-
return {
|
|
2701
|
+
return {
|
|
2702
|
+
startDate,
|
|
2703
|
+
endDate: end,
|
|
2704
|
+
fetched,
|
|
2705
|
+
added,
|
|
2706
|
+
removed,
|
|
2707
|
+
refused,
|
|
2708
|
+
pruned,
|
|
2709
|
+
isFirstSync,
|
|
2710
|
+
...options.prune === undefined ? {} : { pruneScope: options.prune }
|
|
2711
|
+
};
|
|
2230
2712
|
}
|
|
2231
2713
|
|
|
2232
2714
|
// src/db/queries.ts
|
|
@@ -2298,6 +2780,27 @@ function getStats(db, today) {
|
|
|
2298
2780
|
|
|
2299
2781
|
// src/render/format.ts
|
|
2300
2782
|
init_source();
|
|
2783
|
+
|
|
2784
|
+
// src/lib/pad.ts
|
|
2785
|
+
var ANSI = /\u001B\[[0-9;]*m/g;
|
|
2786
|
+
function visibleWidth(text) {
|
|
2787
|
+
return text.replace(ANSI, "").length;
|
|
2788
|
+
}
|
|
2789
|
+
function padLeft(text, width) {
|
|
2790
|
+
const gap = width - visibleWidth(text);
|
|
2791
|
+
return gap > 0 ? " ".repeat(gap) + text : text;
|
|
2792
|
+
}
|
|
2793
|
+
function padRight(text, width) {
|
|
2794
|
+
const gap = width - visibleWidth(text);
|
|
2795
|
+
return gap > 0 ? text + " ".repeat(gap) : text;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// src/lib/terminal.ts
|
|
2799
|
+
function terminalWidth() {
|
|
2800
|
+
return process.stdout.isTTY && process.stdout.columns ? process.stdout.columns : 80;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
// src/render/format.ts
|
|
2301
2804
|
function scoreColor(score) {
|
|
2302
2805
|
if (score === null)
|
|
2303
2806
|
return source_default.gray("\u2014");
|
|
@@ -2351,13 +2854,35 @@ function formatDaySummary(summary, format, emptyHint) {
|
|
|
2351
2854
|
return lines.join(`
|
|
2352
2855
|
`);
|
|
2353
2856
|
}
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2857
|
+
var SUMMARY_INDENT = 4;
|
|
2858
|
+
var SUMMARY_GAP = 2;
|
|
2859
|
+
var SUMMARY_MAX_COLUMNS = 4;
|
|
2860
|
+
function columnsThatFit(cellWidth, width) {
|
|
2861
|
+
const fits = Math.floor((width - SUMMARY_INDENT + SUMMARY_GAP) / (cellWidth + SUMMARY_GAP));
|
|
2862
|
+
return Math.max(1, Math.min(SUMMARY_MAX_COLUMNS, fits));
|
|
2863
|
+
}
|
|
2864
|
+
function rowsOf(cells, columns) {
|
|
2865
|
+
const rows = [];
|
|
2866
|
+
for (let i = 0;i < cells.length; i += columns) {
|
|
2867
|
+
rows.push(" ".repeat(SUMMARY_INDENT) + cells.slice(i, i + columns).join(" ".repeat(SUMMARY_GAP)).trimEnd());
|
|
2868
|
+
}
|
|
2869
|
+
return rows;
|
|
2870
|
+
}
|
|
2871
|
+
function formatImportSummary(result, width = terminalWidth()) {
|
|
2872
|
+
const counts = COLLECTIONS.map((c) => ({
|
|
2873
|
+
name: c.name,
|
|
2874
|
+
fetched: String(result.fetched[c.table] ?? 0),
|
|
2875
|
+
added: String(result.added[c.table] ?? 0)
|
|
2876
|
+
}));
|
|
2877
|
+
const nameW = Math.max(...counts.map((c) => visibleWidth(c.name)));
|
|
2878
|
+
const fetchedW = Math.max(...counts.map((c) => visibleWidth(c.fetched)));
|
|
2879
|
+
const aligned = counts.map((c) => `${padRight(c.name, nameW)} ${padLeft(c.fetched, fetchedW)} (+${c.added})`);
|
|
2880
|
+
const bare = counts.map((c) => `${c.name} ${c.fetched} (+${c.added})`);
|
|
2881
|
+
const cellW = Math.max(...aligned.map(visibleWidth));
|
|
2882
|
+
const columns = columnsThatFit(cellW, width);
|
|
2883
|
+
const cells = columns === 1 ? bare : aligned.map((c) => padRight(c, cellW));
|
|
2884
|
+
const head = ` Fetched ${result.startDate} \u2192 ${result.endDate}, rows fetched (+new):`;
|
|
2885
|
+
return [head, ...rowsOf(cells, columns)].join(`
|
|
2361
2886
|
`);
|
|
2362
2887
|
}
|
|
2363
2888
|
function formatWeekTable(days, format, emptyHint) {
|
|
@@ -2373,7 +2898,7 @@ function formatWeekTable(days, format, emptyHint) {
|
|
|
2373
2898
|
}
|
|
2374
2899
|
const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} ${"Stress".padEnd(10)}`;
|
|
2375
2900
|
const sep = source_default.gray("\u2500".repeat(56));
|
|
2376
|
-
const rows = days.map((d) => `${d.day
|
|
2901
|
+
const rows = days.map((d) => `${padRight(d.day, 12)} ${padLeft(scoreColor(d.sleep_score), 6)} ${padLeft(scoreColor(d.readiness_score), 6)} ` + `${padLeft(scoreColor(d.activity_score), 9)} ${padLeft(String(d.steps ?? "\u2014"), 7)} ${padRight(d.stress ?? "\u2014", 10)}`);
|
|
2377
2902
|
return [`
|
|
2378
2903
|
Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`)].join(`
|
|
2379
2904
|
`);
|
|
@@ -2422,19 +2947,43 @@ function formatStats(stats, format) {
|
|
|
2422
2947
|
}
|
|
2423
2948
|
|
|
2424
2949
|
// src/commands/sync.ts
|
|
2425
|
-
function resolveWindow(opts) {
|
|
2950
|
+
function resolveWindow(opts, today) {
|
|
2426
2951
|
if (opts.to !== undefined && opts.from === undefined)
|
|
2427
2952
|
throw new CliError("BAD_ARGS", "--to requires --from.");
|
|
2428
2953
|
const from = opts.from === undefined ? undefined : assertCalendarDate(opts.from, "--from");
|
|
2429
2954
|
const to = opts.to === undefined ? undefined : assertCalendarDate(opts.to, "--to");
|
|
2430
2955
|
if (from !== undefined && to !== undefined && from > to)
|
|
2431
2956
|
throw new CliError("BAD_ARGS", `--from (${from}) must not be after --to (${to}).`);
|
|
2957
|
+
const end = to ?? today;
|
|
2958
|
+
if (from !== undefined && from > end) {
|
|
2959
|
+
throw new CliError("BAD_ARGS", `--from (${from}) is after the end of the window (${end}).`, "Pass --to as well to sync a window that ends in the future.");
|
|
2960
|
+
}
|
|
2432
2961
|
return { from, to };
|
|
2433
2962
|
}
|
|
2434
|
-
|
|
2963
|
+
var PRUNE_ALL = "all";
|
|
2964
|
+
function resolvePruneScope(value) {
|
|
2965
|
+
if (value === undefined || value === false)
|
|
2966
|
+
return;
|
|
2967
|
+
const raw = value === true ? "" : String(value);
|
|
2968
|
+
const wanted = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
2969
|
+
if (wanted.length === 0 || wanted.some((n) => n.startsWith("-"))) {
|
|
2970
|
+
throw new CliError("BAD_ARGS", "--prune needs a value naming what to prune.", `Use --prune=<collection> (for example --prune=hr), a comma-separated list, or --prune=${PRUNE_ALL} for every collection.`);
|
|
2971
|
+
}
|
|
2972
|
+
if (wanted.includes(PRUNE_ALL)) {
|
|
2973
|
+
if (wanted.length === 1)
|
|
2974
|
+
return PRUNE_ALL;
|
|
2975
|
+
throw new CliError("BAD_ARGS", `--prune=${PRUNE_ALL} cannot be combined with collection names.`, `Use --prune=${PRUNE_ALL} on its own, or list the collections you mean.`);
|
|
2976
|
+
}
|
|
2977
|
+
const unknown = wanted.filter((n) => byName(n) === undefined);
|
|
2978
|
+
if (unknown.length > 0) {
|
|
2979
|
+
throw new CliError("BAD_ARGS", `--prune: unknown collection${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.`, `Known collections: ${names().join(", ")}.`);
|
|
2980
|
+
}
|
|
2981
|
+
return [...new Set(wanted)];
|
|
2982
|
+
}
|
|
2983
|
+
async function runSync(ctx, window = {}, options = {}) {
|
|
2435
2984
|
const lines = [];
|
|
2436
2985
|
const log = ctx.format === "table" ? (m) => lines.push(m) : undefined;
|
|
2437
|
-
const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window);
|
|
2986
|
+
const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window, options);
|
|
2438
2987
|
const today = getDaySummary(ctx.db, ctx.today);
|
|
2439
2988
|
return {
|
|
2440
2989
|
json: { import: importResult, today },
|
|
@@ -2442,15 +2991,18 @@ async function runSync(ctx, window = {}) {
|
|
|
2442
2991
|
`)
|
|
2443
2992
|
};
|
|
2444
2993
|
}
|
|
2445
|
-
var
|
|
2994
|
+
var syncArgs = {
|
|
2995
|
+
from: { type: "string", description: "Re-fetch every collection from this day (YYYY-MM-DD) instead of from its last stored day" },
|
|
2996
|
+
to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" },
|
|
2997
|
+
prune: { type: "string", description: "Apply removals sync kept back: --prune=hr, a list, or --prune=all" }
|
|
2998
|
+
};
|
|
2999
|
+
var syncDef = {
|
|
2446
3000
|
meta: { name: "sync", description: "Import latest data from Oura API and return today's summary" },
|
|
2447
|
-
args:
|
|
2448
|
-
from: { type: "string", description: "Re-fetch every collection from this day (YYYY-MM-DD) instead of from its last stored day" },
|
|
2449
|
-
to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" }
|
|
2450
|
-
},
|
|
3001
|
+
args: syncArgs,
|
|
2451
3002
|
needs: { db: true, client: true },
|
|
2452
|
-
run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }))
|
|
2453
|
-
}
|
|
3003
|
+
run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today), { prune: resolvePruneScope(args.prune) })
|
|
3004
|
+
};
|
|
3005
|
+
var syncCommand = dataCommand(syncDef);
|
|
2454
3006
|
|
|
2455
3007
|
// src/commands/db.ts
|
|
2456
3008
|
var SYNC_HINT = "Run `oura-cli sync` to download your data. Oura publishes a day's summary after that night's sleep syncs from the ring.";
|
|
@@ -2513,7 +3065,7 @@ function dayLabel(dateStr) {
|
|
|
2513
3065
|
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
2514
3066
|
return `${day} ${dd}/${mm}`;
|
|
2515
3067
|
}
|
|
2516
|
-
function getReport(db, days, today
|
|
3068
|
+
function getReport(db, days, today) {
|
|
2517
3069
|
const period = days <= 7 ? "week" : "month";
|
|
2518
3070
|
const weekEnd = today;
|
|
2519
3071
|
const weekStart = shiftDay(today, -(days - 1));
|
|
@@ -2521,7 +3073,8 @@ function getReport(db, days, today, tz = "UTC") {
|
|
|
2521
3073
|
const prevWeekStart = shiftDay(today, -(days * 2 - 1));
|
|
2522
3074
|
const windowDays = daysBack(today, days);
|
|
2523
3075
|
const lastUpload = db.query("SELECT MAX(timestamp) AS t FROM heartrate").get().t;
|
|
2524
|
-
const
|
|
3076
|
+
const newestActivityDay = db.query("SELECT MAX(day) AS d FROM daily_activity").get().d;
|
|
3077
|
+
const isComplete = (d) => d < today && newestActivityDay !== null && newestActivityDay > d;
|
|
2525
3078
|
const completeThrough = [...windowDays].reverse().find(isComplete) ?? null;
|
|
2526
3079
|
const activityEnd = completeThrough ?? shiftDay(weekStart, -1);
|
|
2527
3080
|
const dailyRows = [];
|
|
@@ -2607,13 +3160,13 @@ function colorizeScore(n) {
|
|
|
2607
3160
|
}
|
|
2608
3161
|
function scoreCell(n, width) {
|
|
2609
3162
|
if (n === null)
|
|
2610
|
-
return source_default.gray("\u2014"
|
|
2611
|
-
return colorizeScore(n)(String(n)
|
|
3163
|
+
return padLeft(source_default.gray("\u2014"), width);
|
|
3164
|
+
return padLeft(colorizeScore(n)(String(n)), width);
|
|
2612
3165
|
}
|
|
2613
3166
|
function stepsCell(n, width) {
|
|
2614
3167
|
if (n === null)
|
|
2615
|
-
return source_default.gray("\u2014"
|
|
2616
|
-
return n.toLocaleString()
|
|
3168
|
+
return padLeft(source_default.gray("\u2014"), width);
|
|
3169
|
+
return padLeft(n.toLocaleString(), width);
|
|
2617
3170
|
}
|
|
2618
3171
|
function fmtSeconds(s) {
|
|
2619
3172
|
if (s === null)
|
|
@@ -2655,18 +3208,15 @@ function bucketDaysIntoWeeks(days) {
|
|
|
2655
3208
|
}
|
|
2656
3209
|
return buckets;
|
|
2657
3210
|
}
|
|
2658
|
-
function partialDayNote(data
|
|
2659
|
-
const partial = data.days.
|
|
2660
|
-
if (partial
|
|
3211
|
+
function partialDayNote(data) {
|
|
3212
|
+
const partial = data.days.find((d) => d.partial);
|
|
3213
|
+
if (!partial)
|
|
2661
3214
|
return null;
|
|
2662
|
-
const
|
|
2663
|
-
const newestLabel = newest.day === data.weekEnd ? "today" : newest.dayLabel;
|
|
2664
|
-
const which = partial.length === 1 ? `${newestLabel} is` : `${newestLabel} and ${partial.length - 1} earlier day${partial.length > 2 ? "s" : ""} are`;
|
|
2665
|
-
const synced = data.lastUpload ? `ring last synced ${formatLocal(data.lastUpload, tz)}` : "ring sync time unknown";
|
|
3215
|
+
const which = partial.day === data.weekEnd ? "today" : partial.dayLabel;
|
|
2666
3216
|
const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
|
|
2667
|
-
return ` * ${which} still accumulating
|
|
3217
|
+
return ` * ${which} is still accumulating; activity averages cover ${covers}.`;
|
|
2668
3218
|
}
|
|
2669
|
-
function formatReport(data, format, period
|
|
3219
|
+
function formatReport(data, format, period) {
|
|
2670
3220
|
if (format === "json")
|
|
2671
3221
|
return JSON.stringify(data, null, 2);
|
|
2672
3222
|
const lines = [];
|
|
@@ -2677,7 +3227,7 @@ function formatReport(data, format, period, tz = "UTC") {
|
|
|
2677
3227
|
lines.push(source_default.bold(" Oura Monthly Report"));
|
|
2678
3228
|
}
|
|
2679
3229
|
lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
|
|
2680
|
-
const note = partialDayNote(data
|
|
3230
|
+
const note = partialDayNote(data);
|
|
2681
3231
|
if (note)
|
|
2682
3232
|
lines.push(source_default.yellow(note));
|
|
2683
3233
|
lines.push("");
|
|
@@ -2769,15 +3319,15 @@ var reportCommand = dataCommand({
|
|
|
2769
3319
|
if (period !== "week" && period !== "month") {
|
|
2770
3320
|
throw new CliError("BAD_ARGS", `--period must be "week" or "month", got "${period}".`);
|
|
2771
3321
|
}
|
|
2772
|
-
const data = getReport(ctx.db, period === "week" ? 7 : 30, ctx.today
|
|
2773
|
-
return { json: data, text: () => formatReport(data, "table", period
|
|
3322
|
+
const data = getReport(ctx.db, period === "week" ? 7 : 30, ctx.today);
|
|
3323
|
+
return { json: data, text: () => formatReport(data, "table", period) };
|
|
2774
3324
|
}
|
|
2775
3325
|
});
|
|
2776
3326
|
|
|
2777
3327
|
// src/commands/healthcheck.ts
|
|
2778
3328
|
function healthcheckCommand(version) {
|
|
2779
3329
|
return defineCommand({
|
|
2780
|
-
meta: { name: "healthcheck", description: "
|
|
3330
|
+
meta: { name: "healthcheck", description: "Fast liveness probe: opens the local database and runs one query (JSON: {ok, version, latencyMs}, plus error when ok is false). It proves the file opens, not that its contents are intact \u2014 `doctor` checks that." },
|
|
2781
3331
|
args: { ...commonArgs },
|
|
2782
3332
|
run({ args }) {
|
|
2783
3333
|
assertKnownArgs(commonArgs, args);
|
|
@@ -2858,8 +3408,18 @@ async function runChecks(deps) {
|
|
|
2858
3408
|
checks.push({ id: "database", status: "fail", detail: msg });
|
|
2859
3409
|
}
|
|
2860
3410
|
if (db) {
|
|
2861
|
-
const
|
|
2862
|
-
|
|
3411
|
+
const damage = quickCheck(db);
|
|
3412
|
+
checks.push(damage === null ? { id: "integrity", status: "ok", detail: "Database passes SQLite quick_check." } : { id: "integrity", status: "fail", detail: `Database is damaged: ${damage}`, fix: "Delete the cache file (--db / OURA_DB_PATH) and run `oura-cli sync` to rebuild it." });
|
|
3413
|
+
let last = null;
|
|
3414
|
+
let readFailed;
|
|
3415
|
+
try {
|
|
3416
|
+
last = latestDataDay(db);
|
|
3417
|
+
} catch (err) {
|
|
3418
|
+
readFailed = err instanceof Error ? err.message : String(err);
|
|
3419
|
+
}
|
|
3420
|
+
if (readFailed !== undefined) {
|
|
3421
|
+
checks.push({ id: "data", status: "fail", detail: `Cannot read the cache: ${readFailed}`, fix: "Delete the cache file (--db / OURA_DB_PATH) and run `oura-cli sync` to rebuild it." });
|
|
3422
|
+
} else if (!last) {
|
|
2863
3423
|
checks.push({ id: "data", status: "warn", detail: "No data in the local cache yet.", fix: "oura-cli sync" });
|
|
2864
3424
|
} else {
|
|
2865
3425
|
const ageDays = Math.round((new Date(`${deps.today}T00:00:00Z`).getTime() - new Date(`${last}T00:00:00Z`).getTime()) / 86400000);
|
|
@@ -2870,6 +3430,7 @@ async function runChecks(deps) {
|
|
|
2870
3430
|
}
|
|
2871
3431
|
}
|
|
2872
3432
|
} else {
|
|
3433
|
+
checks.push({ id: "integrity", status: "fail", detail: "Cannot check integrity \u2014 database unavailable." });
|
|
2873
3434
|
checks.push({ id: "data", status: "fail", detail: "Cannot check data \u2014 database unavailable." });
|
|
2874
3435
|
}
|
|
2875
3436
|
db?.close();
|
|
@@ -2878,6 +3439,15 @@ async function runChecks(deps) {
|
|
|
2878
3439
|
const nextStep = checks.find((c) => !settled(c.status))?.fix ?? null;
|
|
2879
3440
|
return { ok, checks, nextStep };
|
|
2880
3441
|
}
|
|
3442
|
+
function quickCheck(db) {
|
|
3443
|
+
try {
|
|
3444
|
+
const rows = db.query("PRAGMA quick_check(1)").all();
|
|
3445
|
+
const first = rows[0] === undefined ? "ok" : Object.values(rows[0])[0] ?? "ok";
|
|
3446
|
+
return first === "ok" ? null : first;
|
|
3447
|
+
} catch (err) {
|
|
3448
|
+
return err instanceof Error ? err.message : String(err);
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
2881
3451
|
var DATA_TABLES = ["daily_sleep", "daily_readiness", "daily_activity"];
|
|
2882
3452
|
function latestDataDay(db) {
|
|
2883
3453
|
let latest = null;
|
|
@@ -2896,28 +3466,34 @@ function exitCodeForChecks(checks) {
|
|
|
2896
3466
|
return exitCodeFor(new CliError("TOKEN_MISSING", fail.detail));
|
|
2897
3467
|
return exitCodeFor(new CliError("DB_ERROR", fail.detail));
|
|
2898
3468
|
}
|
|
3469
|
+
async function runDoctor(ctx, args) {
|
|
3470
|
+
const dbPath = getDbPath(args.db);
|
|
3471
|
+
const deps = {
|
|
3472
|
+
resolveToken: () => resolveToken(args.token),
|
|
3473
|
+
openDb: () => {
|
|
3474
|
+
const db = openDatabase(args.db);
|
|
3475
|
+
ensureSchema(db);
|
|
3476
|
+
return { db, path: dbPath };
|
|
3477
|
+
},
|
|
3478
|
+
createClient: (token) => new OuraClient({ token }),
|
|
3479
|
+
offline: args.offline === true,
|
|
3480
|
+
today: ctx.today
|
|
3481
|
+
};
|
|
3482
|
+
const result = await runChecks(deps);
|
|
3483
|
+
return {
|
|
3484
|
+
json: result,
|
|
3485
|
+
text: () => formatDoctorTable(result),
|
|
3486
|
+
exitCode: exitCodeForChecks(result.checks)
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
2899
3489
|
var doctorCommand = dataCommand({
|
|
2900
3490
|
meta: { name: "doctor", description: "Diagnose token, database, and sync health, and suggest the next step." },
|
|
2901
3491
|
args: { offline: { type: "boolean", default: false, description: "Skip the live Oura API token-validation call" } },
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
ensureSchema(db);
|
|
2908
|
-
return { db, path: getDbPath(args.db) };
|
|
2909
|
-
},
|
|
2910
|
-
createClient: (token) => new OuraClient({ token }),
|
|
2911
|
-
offline: args.offline === true,
|
|
2912
|
-
today: ctx.today
|
|
2913
|
-
};
|
|
2914
|
-
const result = await runChecks(deps);
|
|
2915
|
-
return {
|
|
2916
|
-
json: result,
|
|
2917
|
-
text: () => formatDoctorTable(result),
|
|
2918
|
-
exitCode: exitCodeForChecks(result.checks)
|
|
2919
|
-
};
|
|
2920
|
-
}
|
|
3492
|
+
run: (ctx, args) => runDoctor(ctx, {
|
|
3493
|
+
db: args.db,
|
|
3494
|
+
token: args.token,
|
|
3495
|
+
offline: args.offline === true
|
|
3496
|
+
})
|
|
2921
3497
|
});
|
|
2922
3498
|
|
|
2923
3499
|
// src/commands/manifest.ts
|
|
@@ -2979,10 +3555,15 @@ function resolveRange(opts) {
|
|
|
2979
3555
|
}
|
|
2980
3556
|
return { start: opts.today, end: opts.today };
|
|
2981
3557
|
}
|
|
3558
|
+
function assertRangeAllowed(c, opts) {
|
|
3559
|
+
if (c.rangeParams === "none" && [opts.day, opts.from, opts.to, opts.days].some((v) => v !== undefined)) {
|
|
3560
|
+
throw new CliError("BAD_ARGS", `"${c.name}" is a snapshot, not a day range; it takes no --day, --from/--to or --days.`);
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
2982
3563
|
var fetchCommand = dataCommand({
|
|
2983
3564
|
meta: { name: "fetch", description: "Fetch raw records for one Oura collection straight from the API (JSON)." },
|
|
2984
3565
|
args: {
|
|
2985
|
-
collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")}` },
|
|
3566
|
+
collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")} (ring is a snapshot and takes no range flags)` },
|
|
2986
3567
|
day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
|
|
2987
3568
|
from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
|
|
2988
3569
|
to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
|
|
@@ -2993,13 +3574,14 @@ var fetchCommand = dataCommand({
|
|
|
2993
3574
|
const c = byName(args.collection);
|
|
2994
3575
|
if (!c)
|
|
2995
3576
|
throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
|
|
2996
|
-
const
|
|
3577
|
+
const opts = {
|
|
2997
3578
|
day: args.day,
|
|
2998
3579
|
from: args.from,
|
|
2999
3580
|
to: args.to,
|
|
3000
|
-
days: args.days
|
|
3001
|
-
|
|
3002
|
-
|
|
3581
|
+
days: args.days
|
|
3582
|
+
};
|
|
3583
|
+
assertRangeAllowed(c, opts);
|
|
3584
|
+
const { start, end } = resolveRange({ ...opts, today: ctx.today });
|
|
3003
3585
|
const client = new OuraClient(args.token ? { token: args.token } : {});
|
|
3004
3586
|
const data = await fetchCollection(client, c, start, end, ctx.tz);
|
|
3005
3587
|
return { json: data, text: () => JSON.stringify(data, null, 2) };
|
|
@@ -3007,15 +3589,52 @@ var fetchCommand = dataCommand({
|
|
|
3007
3589
|
});
|
|
3008
3590
|
|
|
3009
3591
|
// src/lib/citty-error.ts
|
|
3010
|
-
var
|
|
3011
|
-
|
|
3592
|
+
var ANSI2 = /\u001b\[[0-9;]*m/g;
|
|
3593
|
+
var COMMAND_NAME = /^[a-z][a-z0-9-]{0,19}$/;
|
|
3594
|
+
function editDistanceAtMostOne(a, b) {
|
|
3595
|
+
if (Math.abs(a.length - b.length) > 1)
|
|
3596
|
+
return false;
|
|
3597
|
+
let i = 0, j = 0, edits = 0;
|
|
3598
|
+
while (i < a.length && j < b.length) {
|
|
3599
|
+
if (a[i] === b[j]) {
|
|
3600
|
+
i++;
|
|
3601
|
+
j++;
|
|
3602
|
+
continue;
|
|
3603
|
+
}
|
|
3604
|
+
if (++edits > 1)
|
|
3605
|
+
return false;
|
|
3606
|
+
if (a.length > b.length)
|
|
3607
|
+
i++;
|
|
3608
|
+
else if (a.length < b.length)
|
|
3609
|
+
j++;
|
|
3610
|
+
else {
|
|
3611
|
+
i++;
|
|
3612
|
+
j++;
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
return edits + (a.length - i) + (b.length - j) <= 1;
|
|
3616
|
+
}
|
|
3617
|
+
function nearestGlobalFlag(token) {
|
|
3618
|
+
if (!token.startsWith("--") || token.length < 4)
|
|
3619
|
+
return;
|
|
3620
|
+
return [...GLOBAL_FLAGS_WITH_VALUE].find((flag) => flag !== token && (editDistanceAtMostOne(flag, token) || flag.startsWith(token)));
|
|
3621
|
+
}
|
|
3622
|
+
function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
|
|
3012
3623
|
const code = err?.code;
|
|
3013
3624
|
if (typeof code !== "string")
|
|
3014
3625
|
return err;
|
|
3015
|
-
const message = (err instanceof Error ? err.message : String(err)).replace(
|
|
3626
|
+
const message = (err instanceof Error ? err.message : String(err)).replace(ANSI2, "");
|
|
3016
3627
|
switch (code) {
|
|
3017
3628
|
case "E_UNKNOWN_COMMAND": {
|
|
3018
3629
|
const name = message.replace(/^Unknown command\s*/, "").trim();
|
|
3630
|
+
const before = rawArgs[rawArgs.lastIndexOf(name) - 1];
|
|
3631
|
+
const meant = before === undefined ? undefined : nearestGlobalFlag(before);
|
|
3632
|
+
if (meant) {
|
|
3633
|
+
return new CliError("BAD_ARGS", `Unknown flag "${before}".`, `Did you mean ${meant}? Its value was read as a command name.`);
|
|
3634
|
+
}
|
|
3635
|
+
if (!COMMAND_NAME.test(name)) {
|
|
3636
|
+
return new CliError("BAD_ARGS", "Unknown command.", "A value was read as a command name. Check the flags before it, and run `oura-cli --help` for the list of commands.");
|
|
3637
|
+
}
|
|
3019
3638
|
const hint = Object.hasOwn(removedCommandHints, name) ? removedCommandHints[name] : "Run `oura-cli --help` for the list of commands.";
|
|
3020
3639
|
return new CliError("BAD_ARGS", `Unknown command "${name}".`, hint);
|
|
3021
3640
|
}
|
|
@@ -3030,9 +3649,6 @@ function fromCittyError(err, removedCommandHints = {}) {
|
|
|
3030
3649
|
|
|
3031
3650
|
// src/index.ts
|
|
3032
3651
|
var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
3033
|
-
if (process.argv.includes("--no-color") || process.env.NO_COLOR) {
|
|
3034
|
-
source_default.level = 0;
|
|
3035
|
-
}
|
|
3036
3652
|
var subCommands = Object.assign(Object.create(null), {
|
|
3037
3653
|
login: loginCommand,
|
|
3038
3654
|
describe: describeCommand(VERSION, () => subCommands),
|
|
@@ -3073,7 +3689,7 @@ if (isVersionRequest(rawArgs)) {
|
|
|
3073
3689
|
runMain(main, { rawArgs });
|
|
3074
3690
|
} else {
|
|
3075
3691
|
runCommand(main, { rawArgs }).catch((raw) => {
|
|
3076
|
-
const err = fromCittyError(raw, REMOVED_COMMANDS);
|
|
3692
|
+
const err = fromCittyError(raw, REMOVED_COMMANDS, rawArgs);
|
|
3077
3693
|
emitError(err, formatFromArgv(rawArgs, process.stdout.isTTY === true));
|
|
3078
3694
|
process.exit(exitCodeFor(err));
|
|
3079
3695
|
});
|