@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/dist/index.js CHANGED
@@ -557,8 +557,26 @@ var init_source = __esm(() => {
557
557
  source_default = chalk;
558
558
  });
559
559
 
560
- // src/index.ts
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
- const hint = /database is locked|SQLITE_BUSY/i.test(detail) ? BUSY_HINT : DB_HINT;
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
- if (process.env.OURA_DB_PATH)
1331
- return process.env.OURA_DB_PATH;
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
- const mode = db.query("PRAGMA journal_mode").get();
1346
- if (mode.journal_mode !== "wal")
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
- const current = schemaVersion(db);
1362
- for (const m of migrations) {
1363
- if (m.version > current) {
1364
- db.exec(m.sql);
1365
- db.query("INSERT INTO _schema_version (version) VALUES (?)").run(m.version);
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
- if (process.env.OURA_TOKEN)
1384
- return { token: process.env.OURA_TOKEN.trim(), source: "OURA_TOKEN" };
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 subIdx = rest.findIndex((a) => SUBCOMMANDS.has(a));
1473
- if (subIdx < 0)
1474
- return argv;
1475
- const before = rest.slice(0, subIdx);
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
- const leftover = [];
1479
- for (let i = 0;i < before.length; i++) {
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 < before.length) {
1491
- hoisted.push(before[i + 1]);
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
- leftover.push(tok);
1634
+ kept.push(tok);
1501
1635
  }
1502
- const dd = subcommandOnwards.indexOf("--");
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 resolveFormat({ explicit, isTty }) {
1525
- if (explicit === undefined)
1526
- return isTty ? "table" : "json";
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
- if (process.env.OURA_TZ)
1596
- return process.env.OURA_TZ;
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 fetchCollection(client, c, start, end, tz) {
2086
- const rows = [];
2417
+ async function fetchCollectionByPiece(client, c, start, end, tz) {
2418
+ const pieces = [];
2087
2419
  for (const query of rangeQueries(c, start, end, tz)) {
2088
- for (const row of await client.fetch(c.endpoint, query))
2089
- rows.push(row);
2420
+ pieces.push(await client.fetch(c.endpoint, query));
2090
2421
  }
2091
- return rows;
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}`).get().d;
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 rowCount(db, table) {
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
- return { c, last, start: window.from ?? last ?? backfillStart };
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 isFirstSync = plan.every((p) => p.last === null);
2211
- const startDate = plan.map((p) => p.start).sort()[0];
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 rows = await fetchCollection(client, c, start, end, tz);
2217
- const before = rowCount(db, c.table);
2662
+ const pieces = await fetchCollectionByPiece(client, c, start, end, tz);
2663
+ const rows = pieces.flat();
2218
2664
  const stmt = db.query(insertSql(c));
2219
- db.transaction((rs) => {
2220
- for (const r of rs)
2221
- stmt.run(...rowValues(c, r));
2222
- })(rows);
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] = rowCount(db, c.table) - before;
2225
- if (rows.length > 0)
2226
- _log(` + ${c.table}: ${rows.length} fetched, ${added[c.table]} new`);
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 { startDate, endDate: end, fetched, added, isFirstSync };
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
- function formatImportSummary(result) {
2355
- const n = (table) => `${result.fetched[table] ?? 0} (+${result.added[table] ?? 0})`;
2356
- return [
2357
- ` Fetched ${result.startDate} \u2192 ${result.endDate}, rows fetched (+new):`,
2358
- ` sleep ${n("daily_sleep")} readiness ${n("daily_readiness")} activity ${n("daily_activity")} sleep periods ${n("sleep_model")}`,
2359
- ` spo2 ${n("daily_spo2")} stress ${n("daily_stress")} workouts ${n("workouts")} heart rate ${n("heartrate")} cardiovascular age ${n("cardiovascular_age")}`
2360
- ].join(`
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.padEnd(12)} ${scoreColor(d.sleep_score).padStart(6)} ${scoreColor(d.readiness_score).padStart(6)} ` + `${scoreColor(d.activity_score).padStart(9)} ${String(d.steps ?? "\u2014").padStart(7)} ${(d.stress ?? "\u2014").padEnd(10)}`);
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
- async function runSync(ctx, window = {}) {
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 syncCommand = dataCommand({
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, tz = "UTC") {
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 isComplete = (d) => d < today && (lastUpload === null || Date.parse(localDateToUtcRange(d, tz)[1]) <= Date.parse(lastUpload));
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".padStart(width));
2611
- return colorizeScore(n)(String(n).padStart(width));
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".padStart(width));
2616
- return n.toLocaleString().padStart(width);
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, tz) {
2659
- const partial = data.days.filter((d) => d.partial);
2660
- if (partial.length === 0)
3211
+ function partialDayNote(data) {
3212
+ const partial = data.days.find((d) => d.partial);
3213
+ if (!partial)
2661
3214
  return null;
2662
- const newest = partial[partial.length - 1];
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 (${synced}); activity averages cover ${covers}.`;
3217
+ return ` * ${which} is still accumulating; activity averages cover ${covers}.`;
2668
3218
  }
2669
- function formatReport(data, format, period, tz = "UTC") {
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, tz);
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, ctx.tz);
2773
- return { json: data, text: () => formatReport(data, "table", period, ctx.tz) };
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: "Quick local DB health probe (JSON: {ok, version, latencyMs}, plus error when ok is false)." },
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 last = latestDataDay(db);
2862
- if (!last) {
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
- async run(ctx, args) {
2903
- const deps = {
2904
- resolveToken: () => resolveToken(args.token),
2905
- openDb: () => {
2906
- const db = openDatabase(args.db);
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 { start, end } = resolveRange({
3577
+ const opts = {
2997
3578
  day: args.day,
2998
3579
  from: args.from,
2999
3580
  to: args.to,
3000
- days: args.days,
3001
- today: ctx.today
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 ANSI = /\u001b\[[0-9;]*m/g;
3011
- function fromCittyError(err, removedCommandHints = {}) {
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(ANSI, "");
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
  });