@drakulavich/oura-cli 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +3 -3
- package/dist/index.js +440 -110
- package/docs/schemas/doctor.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.1] - 2026-09-12
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Whether a day's activity totals are final is now read off the day itself. Oura's `class_5_min` carries one character per five-minute slot, so a day reporting a full 288 of them has been closed out; `sync` stores the length in a new `daily_activity.class_5_min_slots` column and `report` reads it. The converse does not hold, and the rule does not claim it: a count *below* 288 is not evidence a day is still open. A day's span is `timestamp(d+1) − timestamp(d)`, so travelling east shortens it — 2023-12-15 came back with 276 slots and 13,637 steps, 2024-01-13 with 270 and 15,736, both closed long ago — while 288 also comes back for 23-hour days and caps longer ones. So the slot count is read as positive evidence of closure only, and a day short of 288 falls back to the old next-day rule rather than being called open. The old rule inferred it instead — a day counted as complete once a *later* day had its own record — which described the ring's behaviour rather than the day, and came apart wherever the two did: a ring that stopped uploading froze its last day as "still accumulating" forever, keeping real steps out of every average; a report timezone west of the ring's discarded a day the cache had already closed; and `completeThrough` could name a day holding no activity record at all, promising an average over a day the table never printed. Rows written before the column exists keep NULL and take the same fallback, so an upgraded cache fills in as `sync` re-fetches each day — `sync --from` fills older days on demand. Because the slot count can only ever close a day and never reopen one, upgrading never makes a day with an activity record *less* complete than it already was: over a real 1,044-day cache the two rules together name exactly the days the old rule named. (#74)
|
|
13
|
+
- `db trends`, `db stats` and `db week` now use that same judgement, so the screens stop disagreeing about one week (the all-time averages in `db stats` move by a few steps for the same reason: the day in progress no longer counts). `db trends` averaged the day in progress as though it were whole while `report` excluded it, and `db week` showed its part-day steps unmarked: the same seven days gave a 7,610 step average in one place and 6,639 in another. Activity, steps and active calories now stop at the last complete day in the window; sleep and readiness are final once they exist and still cover all of it. The week table marks the day with `*` and `DaySummary` carries `partial`, matching what `report` has always shown. (#75)
|
|
14
|
+
- `sync` no longer aborts on a heart-rate sample without a `timestamp`. The `day` column is derived from it, so a null threw a `TypeError` at insert time, outside the CLI's error mapping, and every collection after `hr` in the run never synced. A row whose identity field is missing, null, empty, or not the type the column stores is now dropped before insert, counted in the JSON under `dropped` and named on the collection's line (`1 dropped (no timestamp/source)`); the row was unusable anyway, since those columns are what it would have been stored under. A row is judged by the columns its table keys on, not by the manifest's identity fields: a daily summary Oura sends without an `id` is still stored under its `day`. (#106)
|
|
15
|
+
- An empty `200` from a snapshot endpoint no longer clears its table. `ring` is fetched whole every run and was replaced with whatever came back, so a partial read or an upstream hiccup deleted every ring and the JSON showed nothing in `removed`. The snapshot path now follows the rule the ranged collections already had: an empty answer describes nothing, so the rows are kept and reported under `refused` with the `--prune=ring` hint; a ring genuinely gone from the account is applied with that flag. Rings a non-empty response no longer lists are counted in `removed`, as they always should have been. (#105)
|
|
16
|
+
- `db trends` and `db stats` say what is missing on an empty cache instead of printing a header over nothing, or seventeen lines of `0 rows`. Both now carry the same "run `oura-cli sync`, then this command again" line that `db today` and `db week` already print. (#85)
|
|
17
|
+
- `report --period month` chunks its week buckets from the newest day back, so the remainder of a 30-day window is the oldest row and is labelled with its size (`2026-08-13 (2 days)`), not an unlabelled stub at the bottom that read as activity collapsing by 85 %. The `*` note names the bucket it marks (`the week of …`) instead of saying "today" above a table with no day rows. (#84)
|
|
18
|
+
- The `describe` and `manifest` snapshots now cover the real command registry. The old snapshot was built from a hand-listed fixture that did not include `sync`, so #103 added `--prune` to a published contract and no test moved. The registry lives in `src/commands/registry.ts`, the contract test asserts it matches the argv normalizer's command set, and the fixture-based tests stay as they were, since a controlled input is what they need. (#104)
|
|
19
|
+
|
|
20
|
+
## [0.7.0] - 2026-09-09
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- `sync --prune=<collections>` applies removals the truncation guard refuses. The guard keeps rows when a response drops most of what one request described, which is right for a partial or short answer and wrong for a genuine large correction — and it cannot tell them apart, so a genuine one was refused on every run with no way through. Narrowing the window did not help: the scope is the returned rows' own bounds, so the ratio never moved, and `sync --from D --to D` refused identically. The flag is the user's answer to the question the guard cannot ask, per run and never stored.
|
|
24
|
+
|
|
25
|
+
It is scoped to the collections named because consent is per collection: the refusal message names the collection to pass (`re-run with --prune=hr`), so lifting the guard for one does not lift it for another that answers short in the same run — a loss that never comes back behind `--from`, since the next ordinary sync resumes from the watermark. `--prune=all` covers every collection; a valueless `--prune` is refused, because as a string flag it would otherwise swallow whatever came next on the line. Removals that went past the guard are counted separately from ordinary reconciliation, in the JSON as `pruned` and on the collection line as `(N past the truncation guard)`, so a bypass is never silent. `refused` and `pruned` carry the collection name alongside the count (`{rows, collection}`), because they are the only fields that imply a follow-up command and the command takes a name the table-keyed payload did not contain — `sync` is JSON-only off a TTY, and an agent that cannot map `heartrate` back to `hr` would reach for `--prune=all`. A run that passed the flag also records `pruneScope`, so a kept JSON log can say whether the guard was lifted and over what. A piece that returns nothing still removes nothing, flag or not. Where two pieces overlap, every piece covering a row is consulted before that row is decided, and one doubting piece is enough to keep it — a verdict taken while walking the pieces would have been settled by whichever came first, so the same responses in a different order could delete a row instead of keeping it. (#100)
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- `sync` kept both rows when Oura reclassified a heart-rate sample (`awake` → `workout` shares the timestamp, and the unique index is `(timestamp, source)`), and no command could repair it. A re-fetched window is now reconciled against the response: rows the API no longer has are removed, inside the same transaction as the inserts. Verified on a copy of a real cache, where it removed exactly the five stale samples the exploratory session had found. (#91)
|
|
29
|
+
- The same for the five tables whose `day` is not unique (`sleep_model`, `workouts`, `sessions`, `rest_mode_periods`, `enhanced_tags`): a record re-issued under a new id used to leave both rows, so `report` averaged one night twice. The scope of a removal always comes from the response and from one request at a time — a range wider than the endpoint allows is fetched in pieces, and a piece that answers with nothing describes nothing, so its days keep their rows. A piece whose answer would drop most of what is stored for it is treated as truncated: the removal is refused and reported. (#71)
|
|
30
|
+
- `sync` reports `removed` and `refused` per table in its JSON, and the progress line says how many stale rows went and how many were kept because the response looked truncated.
|
|
31
|
+
- `ensureSchema` read the schema version and applied migrations with nothing serialising the two, so concurrent commands recorded the same migration several times — harmless only while every migration is `CREATE ... IF NOT EXISTS`, and a hard failure for the loser as soon as one is an `ALTER TABLE`. Check and apply now run under `BEGIN IMMEDIATE`. Two processes creating the same cache also raced on `PRAGMA journal_mode = WAL`, which answers `SQLITE_BUSY` without waiting out `busy_timeout`; losing that race is no longer an error, since the winner set the mode this process wanted. (#77)
|
|
32
|
+
- A misspelled global flag left its value in the command position, so the CLI reported the value as the command name — and for `--tok <token>` that put a Personal Access Token into an error message. A near miss of a known flag is now blamed on the flag (`Unknown flag "--tok". Did you mean --token?`), and a value that cannot be a command name is never quoted back. (#95)
|
|
33
|
+
- An empty `--token` or `OURA_TOKEN` fell through to the token file, so a wrapper expanding an unset variable authenticated as whoever that file holds; an empty `OURA_TZ` fell through to the system zone and shifted every day boundary silently. Both are now `BAD_ARGS`, like the `--db` case fixed in 0.6.0, through one shared guard. (#92)
|
|
34
|
+
- A database in a directory the user cannot write reported `attempt to write a readonly database` with a hint about the file's format. The file is fine; SQLite needs to create `-wal` and `-shm` beside it. Permission failures now get their own hint. (#78)
|
|
35
|
+
- `doctor` runs SQLite's `quick_check` and reports it as a new `integrity` check, so a cache with a damaged page is named instead of passing every command until something touches the broken page. `doctor` also survives reading a corrupt cache rather than crashing part-way through its own checks, and `healthcheck`'s description now says it proves the file opens, not that its contents are intact. (#78)
|
|
36
|
+
|
|
9
37
|
## [0.6.0] - 2026-09-06
|
|
10
38
|
|
|
11
39
|
### Fixed
|
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ oura-cli sync # first sync fetches the last 30 days; later syncs resume from
|
|
|
54
54
|
oura-cli report # weekly digest in the terminal
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
Subsequent `oura-cli sync` re-fetches each collection from its own last stored day (Oura revises recent days, so the overlap is deliberate) and reports rows fetched (+new). Heart rate goes back two weeks each time, because Oura publishes workout samples days after the day they belong to. `oura-cli sync --from 2026-08-01 [--to 2026-08-07]` re-fetches an explicit window for every collection instead — for example after an interrupted sync. `oura-cli db today` / `oura-cli db week` read the local cache instantly, no API call.
|
|
57
|
+
Subsequent `oura-cli sync` re-fetches each collection from its own last stored day (Oura revises recent days, so the overlap is deliberate) and reports rows fetched (+new). Heart rate goes back two weeks each time, because Oura publishes workout samples days after the day they belong to. A re-fetched window ends up holding exactly what the API returned for it: a sample Oura reclassified, or a record it re-issued under a new id, replaces the row it supersedes instead of joining it. When a response drops most of what one request covered — the shape of a partial or short answer — those rows are kept rather than deleted, and `sync` names the collection and how many. `oura-cli sync --prune=hr` then applies them for that collection once you have judged the correction genuine; `oura-cli sync --prune=all` does it for every collection in the run, which also lifts the guard on any collection that answers short in the same run. `oura-cli sync --from 2026-08-01 [--to 2026-08-07]` re-fetches an explicit window for every collection instead — for example after an interrupted sync. `oura-cli db today` / `oura-cli db week` read the local cache instantly, no API call.
|
|
58
58
|
|
|
59
59
|
### If something looks wrong
|
|
60
60
|
|
|
@@ -96,7 +96,7 @@ oura-cli report # weekly (default)
|
|
|
96
96
|
oura-cli report --period month # 30-day window with weekly buckets
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
Reports cover daily scores, averages, deltas vs the previous window, sleep details, and a short recommendation block. A day whose activity is still accumulating
|
|
99
|
+
Reports cover daily scores, averages, deltas vs the previous window, sleep details, and a short recommendation block. A day whose activity is still accumulating — normally just today — is shown with a `*` and kept out of the activity averages and recommendations; the JSON says so via `days[].partial` and `completeThrough`. `db today`, `db date` and `db week` carry the same `partial` flag, and `db week` marks the day with a `*` too, so the two screens cannot disagree. A day is treated as closed once Oura reports a full 24 hours of five-minute activity slots for it, so a ring that stops syncing no longer freezes its last day as unfinished.
|
|
100
100
|
|
|
101
101
|
### Trends and stats
|
|
102
102
|
|
|
@@ -183,7 +183,7 @@ Runtime: [Bun](https://bun.sh). Storage: built-in `bun:sqlite`. CLI parsing: [ci
|
|
|
183
183
|
If you're driving the CLI from a script or LLM harness:
|
|
184
184
|
|
|
185
185
|
- `oura-cli describe` — JSON manifest of every command, argument, and output schema. Agents discover capabilities without scraping `--help`.
|
|
186
|
-
- `oura-cli healthcheck` — `{ok, version, latencyMs}` JSON for liveness probes, plus `error` when `ok` is false.
|
|
186
|
+
- `oura-cli healthcheck` — `{ok, version, latencyMs}` JSON for liveness probes, plus `error` when `ok` is false. It proves the database opens and answers a query; it does not inspect the contents. `oura-cli doctor` runs SQLite's `quick_check` for that.
|
|
187
187
|
- Gate on `.ok`, not on the exit code: `doctor` exits 0 with `ok: false` for any warning-level check (no data yet, stale data, Oura API unreachable), and `healthcheck` exits 0 with `ok: false` for an unusable database (the probe itself ran). `doctor --offline` skips the token-validation call, and a skipped check still counts towards `ok`.
|
|
188
188
|
- Errors emit a stable JSON envelope on stderr: `{"error":{"code":"…","message":"…","hint":"…"}}`.
|
|
189
189
|
- Documented exit codes: `0` success, `1` user error, `2` auth, `3` API, `4` storage.
|
package/dist/index.js
CHANGED
|
@@ -1198,6 +1198,14 @@ import { resolve, dirname } from "path";
|
|
|
1198
1198
|
import { homedir } from "os";
|
|
1199
1199
|
import { chmodSync, existsSync, mkdirSync } from "fs";
|
|
1200
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
|
+
|
|
1201
1209
|
// src/db/migrations.ts
|
|
1202
1210
|
var MIGRATIONS = [
|
|
1203
1211
|
{
|
|
@@ -1399,33 +1407,42 @@ CREATE TABLE IF NOT EXISTS ring_battery_level (
|
|
|
1399
1407
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_ring_battery_level_unique ON ring_battery_level(timestamp);
|
|
1400
1408
|
CREATE INDEX IF NOT EXISTS idx_ring_battery_level_day ON ring_battery_level(day);
|
|
1401
1409
|
`
|
|
1410
|
+
},
|
|
1411
|
+
{
|
|
1412
|
+
version: 4,
|
|
1413
|
+
sql: `
|
|
1414
|
+
ALTER TABLE daily_activity ADD COLUMN class_5_min_slots INTEGER;
|
|
1415
|
+
`
|
|
1402
1416
|
}
|
|
1403
1417
|
];
|
|
1404
1418
|
|
|
1405
1419
|
// src/db/open.ts
|
|
1406
1420
|
var DB_HINT = "Check the path in --db / OURA_DB_PATH and that the file is a SQLite database oura-cli created.";
|
|
1407
1421
|
var BUSY_HINT = "Another oura-cli process is using this database; wait for it to finish and retry.";
|
|
1422
|
+
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.";
|
|
1408
1423
|
var BUSY_TIMEOUT_MS = 5000;
|
|
1424
|
+
var WAL_SWITCH_ATTEMPTS = 20;
|
|
1425
|
+
var WAL_SWITCH_WAIT_MS = 25;
|
|
1426
|
+
function hintFor(detail) {
|
|
1427
|
+
if (/database is locked|SQLITE_BUSY/i.test(detail))
|
|
1428
|
+
return BUSY_HINT;
|
|
1429
|
+
if (/readonly database|read-only|EACCES|permission denied|unable to open database file/i.test(detail))
|
|
1430
|
+
return PERMISSION_HINT;
|
|
1431
|
+
return DB_HINT;
|
|
1432
|
+
}
|
|
1409
1433
|
function dbError(what, err) {
|
|
1410
1434
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1411
|
-
|
|
1412
|
-
return new CliError("DB_ERROR", `${what}: ${detail}`, hint);
|
|
1435
|
+
return new CliError("DB_ERROR", `${what}: ${detail}`, hintFor(detail));
|
|
1413
1436
|
}
|
|
1414
1437
|
function asDbError(err) {
|
|
1415
1438
|
return err instanceof SQLiteError ? dbError("Database query failed", err) : undefined;
|
|
1416
1439
|
}
|
|
1417
|
-
function requirePath(value, source) {
|
|
1418
|
-
if (value.trim() === "") {
|
|
1419
|
-
throw new CliError("BAD_ARGS", `${source} has no path`, `Pass a path to a SQLite file, or remove ${source} to fall back to the default database.`);
|
|
1420
|
-
}
|
|
1421
|
-
return value;
|
|
1422
|
-
}
|
|
1423
1440
|
function getDbPath(explicit) {
|
|
1424
1441
|
if (explicit !== undefined)
|
|
1425
|
-
return
|
|
1442
|
+
return requireValue(explicit, "--db", "the default database");
|
|
1426
1443
|
const fromEnv = process.env.OURA_DB_PATH;
|
|
1427
1444
|
if (fromEnv !== undefined)
|
|
1428
|
-
return
|
|
1445
|
+
return requireValue(fromEnv, "OURA_DB_PATH", "the default database");
|
|
1429
1446
|
return resolve(homedir(), ".oura-cli", "oura.db");
|
|
1430
1447
|
}
|
|
1431
1448
|
function openDatabase(explicit) {
|
|
@@ -1439,15 +1456,31 @@ function openDatabase(explicit) {
|
|
|
1439
1456
|
if (isNew)
|
|
1440
1457
|
chmodSync(dbPath, 384);
|
|
1441
1458
|
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
1459
|
+
if (journalMode(db) !== "wal")
|
|
1460
|
+
enableWal(db);
|
|
1445
1461
|
db.exec("PRAGMA foreign_keys = ON");
|
|
1446
1462
|
return db;
|
|
1447
1463
|
} catch (err) {
|
|
1448
1464
|
throw dbError(`Cannot open database ${dbPath}`, err);
|
|
1449
1465
|
}
|
|
1450
1466
|
}
|
|
1467
|
+
function journalMode(db) {
|
|
1468
|
+
return db.query("PRAGMA journal_mode").get().journal_mode;
|
|
1469
|
+
}
|
|
1470
|
+
function enableWal(db) {
|
|
1471
|
+
for (let attempt = 0;; attempt++) {
|
|
1472
|
+
try {
|
|
1473
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
1474
|
+
return;
|
|
1475
|
+
} catch (err) {
|
|
1476
|
+
if (journalMode(db) === "wal")
|
|
1477
|
+
return;
|
|
1478
|
+
if (attempt >= WAL_SWITCH_ATTEMPTS)
|
|
1479
|
+
throw err;
|
|
1480
|
+
Bun.sleepSync(WAL_SWITCH_WAIT_MS + Math.random() * WAL_SWITCH_WAIT_MS);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1451
1484
|
function schemaVersion(db) {
|
|
1452
1485
|
db.exec("CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)");
|
|
1453
1486
|
const row = db.query("SELECT MAX(version) AS v FROM _schema_version").get();
|
|
@@ -1455,12 +1488,23 @@ function schemaVersion(db) {
|
|
|
1455
1488
|
}
|
|
1456
1489
|
function ensureSchema(db, migrations = MIGRATIONS) {
|
|
1457
1490
|
try {
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1491
|
+
if (schemaVersion(db) >= Math.max(0, ...migrations.map((m) => m.version)))
|
|
1492
|
+
return;
|
|
1493
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1494
|
+
try {
|
|
1495
|
+
const current = schemaVersion(db);
|
|
1496
|
+
for (const m of migrations) {
|
|
1497
|
+
if (m.version > current) {
|
|
1498
|
+
db.exec(m.sql);
|
|
1499
|
+
db.query("INSERT INTO _schema_version (version) VALUES (?)").run(m.version);
|
|
1500
|
+
}
|
|
1463
1501
|
}
|
|
1502
|
+
db.exec("COMMIT");
|
|
1503
|
+
} catch (err) {
|
|
1504
|
+
try {
|
|
1505
|
+
db.exec("ROLLBACK");
|
|
1506
|
+
} catch {}
|
|
1507
|
+
throw err;
|
|
1464
1508
|
}
|
|
1465
1509
|
} catch (err) {
|
|
1466
1510
|
throw dbError("Schema migration failed", err);
|
|
@@ -1475,10 +1519,11 @@ function defaultTokenPath() {
|
|
|
1475
1519
|
return process.env.OURA_TOKEN_PATH ?? resolve2(homedir2(), ".oura-token");
|
|
1476
1520
|
}
|
|
1477
1521
|
function resolveToken(explicit, tokenPath) {
|
|
1478
|
-
if (explicit)
|
|
1479
|
-
return { token: explicit.trim(), source: "--token" };
|
|
1480
|
-
|
|
1481
|
-
|
|
1522
|
+
if (explicit !== undefined)
|
|
1523
|
+
return { token: requireValue(explicit, "--token", "the token file").trim(), source: "--token" };
|
|
1524
|
+
const fromEnv = process.env.OURA_TOKEN;
|
|
1525
|
+
if (fromEnv !== undefined)
|
|
1526
|
+
return { token: requireValue(fromEnv, "OURA_TOKEN", "the token file").trim(), source: "OURA_TOKEN" };
|
|
1482
1527
|
const path = tokenPath ?? defaultTokenPath();
|
|
1483
1528
|
try {
|
|
1484
1529
|
return { token: readFileSync(path, "utf-8").trim(), source: path };
|
|
@@ -1685,8 +1730,9 @@ function localDateToUtcRange(localDate, timezone) {
|
|
|
1685
1730
|
return [iso(localMidnightMs(localDate, timezone)), iso(localMidnightMs(shiftDay(localDate, 1), timezone))];
|
|
1686
1731
|
}
|
|
1687
1732
|
function resolveDefaultTimezone() {
|
|
1688
|
-
|
|
1689
|
-
|
|
1733
|
+
const fromEnv = process.env.OURA_TZ;
|
|
1734
|
+
if (fromEnv !== undefined)
|
|
1735
|
+
return requireValue(fromEnv, "OURA_TZ", "the system timezone");
|
|
1690
1736
|
try {
|
|
1691
1737
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
1692
1738
|
} catch {
|
|
@@ -1973,7 +2019,8 @@ var activity = defineCollection({
|
|
|
1973
2019
|
{ name: "total_calories", type: "INTEGER", pick: (r) => r.total_calories },
|
|
1974
2020
|
{ name: "target_calories", type: "INTEGER", pick: (r) => r.target_calories },
|
|
1975
2021
|
{ name: "contributors", type: "TEXT", pick: (r) => JSON.stringify(r.contributors) },
|
|
1976
|
-
{ name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
|
|
2022
|
+
{ name: "timestamp", type: "TEXT", pick: (r) => r.timestamp },
|
|
2023
|
+
{ name: "class_5_min_slots", type: "INTEGER", pick: (r) => r.class_5_min?.length ?? null }
|
|
1977
2024
|
]
|
|
1978
2025
|
});
|
|
1979
2026
|
|
|
@@ -2345,6 +2392,24 @@ function insertSql(c) {
|
|
|
2345
2392
|
function rowValues(c, row) {
|
|
2346
2393
|
return c.columns.map((col) => col.pick(row));
|
|
2347
2394
|
}
|
|
2395
|
+
function identityColumns(c) {
|
|
2396
|
+
const unique = c.columns.filter((col) => col.unique).map((col) => col.name);
|
|
2397
|
+
if (unique.length > 0)
|
|
2398
|
+
return unique;
|
|
2399
|
+
const pk = c.columns.filter((col) => col.pk).map((col) => col.name);
|
|
2400
|
+
if (pk.length > 0)
|
|
2401
|
+
return pk;
|
|
2402
|
+
return (c.indexes ?? []).find((i) => i.unique)?.columns ?? [];
|
|
2403
|
+
}
|
|
2404
|
+
function hasIdentity(c, row) {
|
|
2405
|
+
if (row == null || typeof row !== "object")
|
|
2406
|
+
return false;
|
|
2407
|
+
return identityColumns(c).every((name) => {
|
|
2408
|
+
const col = c.columns.find((k) => k.name === name);
|
|
2409
|
+
const v = col?.pick(row);
|
|
2410
|
+
return col?.type === "TEXT" ? typeof v === "string" && v !== "" : typeof v === "number" && Number.isFinite(v);
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2348
2413
|
var MS_PER_DAY = 86400000;
|
|
2349
2414
|
function dateQueries(start, end, maxDays, offset) {
|
|
2350
2415
|
const query = (s, e) => ({ start_date: shiftDay(s, offset[0]), end_date: shiftDay(e, offset[1]) });
|
|
@@ -2374,13 +2439,15 @@ function rangeQueries(c, start, end, tz) {
|
|
|
2374
2439
|
return [];
|
|
2375
2440
|
return c.rangeParams === "date" ? dateQueries(start, end, c.maxRangeDays, c.dayRangeOffset ?? [0, 0]) : datetimeQueries(start, end, tz, c.maxRangeDays);
|
|
2376
2441
|
}
|
|
2377
|
-
async function
|
|
2378
|
-
const
|
|
2442
|
+
async function fetchCollectionByPiece(client, c, start, end, tz) {
|
|
2443
|
+
const pieces = [];
|
|
2379
2444
|
for (const query of rangeQueries(c, start, end, tz)) {
|
|
2380
|
-
|
|
2381
|
-
rows.push(row);
|
|
2445
|
+
pieces.push(await client.fetch(c.endpoint, query));
|
|
2382
2446
|
}
|
|
2383
|
-
return
|
|
2447
|
+
return pieces;
|
|
2448
|
+
}
|
|
2449
|
+
async function fetchCollection(client, c, start, end, tz) {
|
|
2450
|
+
return (await fetchCollectionByPiece(client, c, start, end, tz)).flat();
|
|
2384
2451
|
}
|
|
2385
2452
|
|
|
2386
2453
|
// src/commands/describe.ts
|
|
@@ -2482,15 +2549,107 @@ function describeCommand(version, getCommands) {
|
|
|
2482
2549
|
});
|
|
2483
2550
|
}
|
|
2484
2551
|
|
|
2552
|
+
// src/db/reconcile.ts
|
|
2553
|
+
var MAX_REMOVED_SHARE = 0.5;
|
|
2554
|
+
var ALWAYS_SAFE_TO_REMOVE = 5;
|
|
2555
|
+
var KEY_SEPARATOR = "\x00";
|
|
2556
|
+
function emptyPlan() {
|
|
2557
|
+
return { added: 0, stale: [], refused: 0, bypassed: 0 };
|
|
2558
|
+
}
|
|
2559
|
+
function keyOf(values) {
|
|
2560
|
+
return values.map((v) => String(v)).join(KEY_SEPARATOR);
|
|
2561
|
+
}
|
|
2562
|
+
function planWindow(db, c, pieces, options = {}) {
|
|
2563
|
+
const identity = identityColumns(c);
|
|
2564
|
+
if (identity.length === 0)
|
|
2565
|
+
return emptyPlan();
|
|
2566
|
+
const pickOf = (name) => c.columns.find((col) => col.name === name)?.pick;
|
|
2567
|
+
const identityPicks = identity.map(pickOf);
|
|
2568
|
+
const scopeName = c.rangeParams === "datetime" ? "timestamp" : "day";
|
|
2569
|
+
const scopePick = pickOf(scopeName);
|
|
2570
|
+
if (scopePick === undefined || identityPicks.some((p) => p === undefined))
|
|
2571
|
+
return emptyPlan();
|
|
2572
|
+
const keyFor = (row) => keyOf(identityPicks.map((pick) => pick(row)));
|
|
2573
|
+
const wanted = new Set(pieces.flat().map(keyFor));
|
|
2574
|
+
const plan = emptyPlan();
|
|
2575
|
+
const judged = pieces.map((piece) => {
|
|
2576
|
+
if (piece.length === 0)
|
|
2577
|
+
return null;
|
|
2578
|
+
const scopeValues = piece.map((row) => scopePick(row)).filter((v) => v !== null && v !== undefined).map(String);
|
|
2579
|
+
if (scopeValues.length === 0)
|
|
2580
|
+
return null;
|
|
2581
|
+
const days = [...new Set(scopeValues)];
|
|
2582
|
+
const [where, params] = c.rangeParams === "datetime" ? [`${scopeName} BETWEEN ? AND ?`, [minOf(scopeValues), maxOf(scopeValues)]] : [`${scopeName} IN (${days.map(() => "?").join(", ")})`, days];
|
|
2583
|
+
const stored = db.query(`SELECT ${identity.join(", ")} FROM ${c.table} WHERE ${where}`).all(...params);
|
|
2584
|
+
const storedKeys = new Set;
|
|
2585
|
+
const stale = [];
|
|
2586
|
+
for (const row of stored) {
|
|
2587
|
+
const values = identity.map((name) => row[name] ?? null);
|
|
2588
|
+
const key = keyOf(values);
|
|
2589
|
+
storedKeys.add(key);
|
|
2590
|
+
if (!wanted.has(key))
|
|
2591
|
+
stale.push(values);
|
|
2592
|
+
}
|
|
2593
|
+
const looksTruncated = stale.length > ALWAYS_SAFE_TO_REMOVE && stale.length > stored.length * MAX_REMOVED_SHARE;
|
|
2594
|
+
const fresh = [...new Set(piece.map(keyFor))].filter((key) => !storedKeys.has(key));
|
|
2595
|
+
return { stale, looksTruncated, fresh };
|
|
2596
|
+
});
|
|
2597
|
+
const doubted = new Set;
|
|
2598
|
+
for (const p of judged) {
|
|
2599
|
+
if (p?.looksTruncated)
|
|
2600
|
+
for (const values of p.stale)
|
|
2601
|
+
doubted.add(keyOf(values));
|
|
2602
|
+
}
|
|
2603
|
+
const countedNew = new Set;
|
|
2604
|
+
const countedStale = new Set;
|
|
2605
|
+
for (const p of judged) {
|
|
2606
|
+
if (p === null)
|
|
2607
|
+
continue;
|
|
2608
|
+
for (const key of p.fresh) {
|
|
2609
|
+
if (countedNew.has(key))
|
|
2610
|
+
continue;
|
|
2611
|
+
countedNew.add(key);
|
|
2612
|
+
plan.added += 1;
|
|
2613
|
+
}
|
|
2614
|
+
for (const values of p.stale) {
|
|
2615
|
+
const key = keyOf(values);
|
|
2616
|
+
if (countedStale.has(key))
|
|
2617
|
+
continue;
|
|
2618
|
+
countedStale.add(key);
|
|
2619
|
+
if (doubted.has(key) && !options.prune) {
|
|
2620
|
+
plan.refused += 1;
|
|
2621
|
+
} else {
|
|
2622
|
+
if (doubted.has(key))
|
|
2623
|
+
plan.bypassed += 1;
|
|
2624
|
+
plan.stale.push(values);
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
return plan;
|
|
2629
|
+
}
|
|
2630
|
+
function applyWindowPlan(db, c, plan) {
|
|
2631
|
+
if (plan.stale.length === 0)
|
|
2632
|
+
return 0;
|
|
2633
|
+
const identity = identityColumns(c);
|
|
2634
|
+
const del = db.query(`DELETE FROM ${c.table} WHERE ${identity.map((name) => `${name} IS ?`).join(" AND ")}`);
|
|
2635
|
+
let removed = 0;
|
|
2636
|
+
for (const values of plan.stale)
|
|
2637
|
+
removed += del.run(...values).changes;
|
|
2638
|
+
return removed;
|
|
2639
|
+
}
|
|
2640
|
+
function minOf(values) {
|
|
2641
|
+
return values.reduce((a, b) => b < a ? b : a);
|
|
2642
|
+
}
|
|
2643
|
+
function maxOf(values) {
|
|
2644
|
+
return values.reduce((a, b) => b > a ? b : a);
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2485
2647
|
// src/db/sync.ts
|
|
2486
2648
|
var BACKFILL_DAYS = 30;
|
|
2487
2649
|
function lastDay(db, table, end) {
|
|
2488
2650
|
return db.query(`SELECT MAX(day) AS d FROM ${table} WHERE day <= ?`).get(end).d;
|
|
2489
2651
|
}
|
|
2490
|
-
function
|
|
2491
|
-
return db.query(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
|
|
2492
|
-
}
|
|
2493
|
-
async function importDaily(db, client, clock, log, window = {}) {
|
|
2652
|
+
async function importDaily(db, client, clock, log, window = {}, options = {}) {
|
|
2494
2653
|
const { today, tz } = clock;
|
|
2495
2654
|
const _log = log ?? (() => {});
|
|
2496
2655
|
const end = window.to ?? today;
|
|
@@ -2505,10 +2664,25 @@ async function importDaily(db, client, clock, log, window = {}) {
|
|
|
2505
2664
|
const isFirstSync = ranged.every((p) => p.last === null);
|
|
2506
2665
|
const startDate = ranged.map((p) => p.resume).sort()[0];
|
|
2507
2666
|
_log(isFirstSync && window.from === undefined ? `First sync \u2014 backfilling the last ${BACKFILL_DAYS} days: ${startDate} \u2192 ${end}` : `Syncing ${startDate} \u2192 ${end}`);
|
|
2667
|
+
if (options.prune !== undefined) {
|
|
2668
|
+
const scope = options.prune === "all" ? "every collection" : options.prune.join(", ");
|
|
2669
|
+
_log(`--prune: applying removals even where a response looks truncated (${scope})`);
|
|
2670
|
+
}
|
|
2508
2671
|
const fetched = {};
|
|
2509
2672
|
const added = {};
|
|
2673
|
+
const removed = {};
|
|
2674
|
+
const dropped = {};
|
|
2675
|
+
const refused = {};
|
|
2676
|
+
const pruned = {};
|
|
2677
|
+
const mayPrune = (name) => options.prune === "all" || (options.prune?.includes(name) ?? false);
|
|
2510
2678
|
for (const { c, start } of plan) {
|
|
2511
|
-
const
|
|
2679
|
+
const returned = await fetchCollectionByPiece(client, c, start, end, tz);
|
|
2680
|
+
const pieces = returned.map((piece) => piece.filter((r) => hasIdentity(c, r)));
|
|
2681
|
+
const rows = pieces.flat();
|
|
2682
|
+
const missing = returned.flat().length - rows.length;
|
|
2683
|
+
if (missing > 0)
|
|
2684
|
+
dropped[c.table] = missing;
|
|
2685
|
+
const droppedTail = missing > 0 ? `, ${missing} dropped (no ${identityColumns(c).join("/")})` : "";
|
|
2512
2686
|
const stmt = db.query(insertSql(c));
|
|
2513
2687
|
if (c.rangeParams === "none") {
|
|
2514
2688
|
const pk = c.columns.find((col) => col.pk)?.name;
|
|
@@ -2516,31 +2690,86 @@ async function importDaily(db, client, clock, log, window = {}) {
|
|
|
2516
2690
|
throw new Error(`Snapshot collection ${c.name} must declare a primary-key column (enforced by the registry tests).`);
|
|
2517
2691
|
const ids = () => new Set(db.query(`SELECT ${pk} AS id FROM ${c.table}`).all().map((r) => r.id));
|
|
2518
2692
|
const known = ids();
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2693
|
+
const unstorable = rows.length === 0 && missing > 0;
|
|
2694
|
+
const refuse = rows.length === 0 && known.size > 0 && (unstorable || !mayPrune(c.name));
|
|
2695
|
+
if (!refuse) {
|
|
2696
|
+
db.transaction((rs) => {
|
|
2697
|
+
db.exec(`DELETE FROM ${c.table}`);
|
|
2698
|
+
for (const r of rs)
|
|
2699
|
+
stmt.run(...rowValues(c, r));
|
|
2700
|
+
})(rows);
|
|
2701
|
+
}
|
|
2702
|
+
const now = ids();
|
|
2703
|
+
const gone = [...known].filter((id) => !now.has(id)).length;
|
|
2704
|
+
fetched[c.table] = rows.length + missing;
|
|
2705
|
+
added[c.table] = [...now].filter((id) => !known.has(id)).length;
|
|
2706
|
+
if (gone > 0)
|
|
2707
|
+
removed[c.table] = gone;
|
|
2708
|
+
if (refuse && !unstorable)
|
|
2709
|
+
refused[c.table] = { rows: known.size, collection: c.name };
|
|
2710
|
+
else if (rows.length === 0 && gone > 0)
|
|
2711
|
+
pruned[c.table] = { rows: gone, collection: c.name };
|
|
2712
|
+
const tail = gone > 0 ? `, ${gone} stale removed${pruned[c.table] ? " (past the truncation guard)" : ""}` : "";
|
|
2713
|
+
const kept = !refuse ? "" : unstorable ? `, ${known.size} rows kept: the response held no storable rows` : `, ${known.size} rows kept that the API did not return \u2014 an empty answer describes nothing; re-run with --prune=${c.name} to apply it`;
|
|
2714
|
+
_log(` + ${c.name} (${c.table}): ${fetched[c.table]} fetched, ${added[c.table]} new${droppedTail}${tail}${kept}`);
|
|
2527
2715
|
continue;
|
|
2528
2716
|
}
|
|
2529
|
-
const
|
|
2530
|
-
|
|
2531
|
-
for (const
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2717
|
+
const { windowPlan, gone } = db.transaction((ps) => {
|
|
2718
|
+
const windowPlan = planWindow(db, c, ps, { prune: mayPrune(c.name) });
|
|
2719
|
+
for (const piece of ps)
|
|
2720
|
+
for (const r of piece)
|
|
2721
|
+
stmt.run(...rowValues(c, r));
|
|
2722
|
+
return { windowPlan, gone: applyWindowPlan(db, c, windowPlan) };
|
|
2723
|
+
}).immediate(pieces);
|
|
2724
|
+
fetched[c.table] = rows.length + missing;
|
|
2725
|
+
added[c.table] = windowPlan.added;
|
|
2726
|
+
if (gone > 0)
|
|
2727
|
+
removed[c.table] = gone;
|
|
2728
|
+
if (windowPlan.refused > 0)
|
|
2729
|
+
refused[c.table] = { rows: windowPlan.refused, collection: c.name };
|
|
2730
|
+
if (windowPlan.bypassed > 0)
|
|
2731
|
+
pruned[c.table] = { rows: windowPlan.bypassed, collection: c.name };
|
|
2732
|
+
const tail = gone > 0 ? `, ${gone} stale removed${windowPlan.bypassed > 0 ? ` (${windowPlan.bypassed} past the truncation guard)` : ""}` : "";
|
|
2733
|
+
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` : "";
|
|
2734
|
+
_log(` + ${c.name} (${c.table}): ${fetched[c.table]} fetched, ${added[c.table]} new${droppedTail}${tail}${kept}`);
|
|
2537
2735
|
}
|
|
2538
2736
|
_log("Import complete.");
|
|
2539
|
-
return {
|
|
2737
|
+
return {
|
|
2738
|
+
startDate,
|
|
2739
|
+
endDate: end,
|
|
2740
|
+
fetched,
|
|
2741
|
+
added,
|
|
2742
|
+
removed,
|
|
2743
|
+
dropped,
|
|
2744
|
+
refused,
|
|
2745
|
+
pruned,
|
|
2746
|
+
isFirstSync,
|
|
2747
|
+
...options.prune === undefined ? {} : { pruneScope: options.prune }
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
// src/db/day-complete.ts
|
|
2752
|
+
var SLOTS_PER_DAY = 288;
|
|
2753
|
+
function dayCompleteness(db, today) {
|
|
2754
|
+
const rows = db.query("SELECT day, class_5_min_slots AS slots FROM daily_activity").all();
|
|
2755
|
+
const slotsByDay = new Map(rows.map((r) => [r.day, r.slots]));
|
|
2756
|
+
const daysDesc = rows.map((r) => r.day).sort().reverse();
|
|
2757
|
+
const newestDay = daysDesc[0] ?? null;
|
|
2758
|
+
const isComplete = (day) => {
|
|
2759
|
+
if (!slotsByDay.has(day))
|
|
2760
|
+
return false;
|
|
2761
|
+
if ((slotsByDay.get(day) ?? 0) >= SLOTS_PER_DAY)
|
|
2762
|
+
return true;
|
|
2763
|
+
return day < today && newestDay !== null && newestDay > day;
|
|
2764
|
+
};
|
|
2765
|
+
return {
|
|
2766
|
+
isComplete,
|
|
2767
|
+
completeThrough: (start, end) => daysDesc.find((d) => d >= start && d <= end && isComplete(d)) ?? null
|
|
2768
|
+
};
|
|
2540
2769
|
}
|
|
2541
2770
|
|
|
2542
2771
|
// src/db/queries.ts
|
|
2543
|
-
function getDaySummary(db, day) {
|
|
2772
|
+
function getDaySummary(db, day, complete) {
|
|
2544
2773
|
const sl = db.query("SELECT score FROM daily_sleep WHERE day=?").get(day);
|
|
2545
2774
|
const rd = db.query("SELECT score, temperature_deviation FROM daily_readiness WHERE day=?").get(day);
|
|
2546
2775
|
const ac = db.query("SELECT score, steps FROM daily_activity WHERE day=?").get(day);
|
|
@@ -2549,6 +2778,7 @@ function getDaySummary(db, day) {
|
|
|
2549
2778
|
const sm = db.query(`SELECT total_sleep_duration, deep_sleep_duration, rem_sleep_duration, average_hrv, lowest_heart_rate, efficiency FROM sleep_model WHERE day=? AND type='long_sleep'`).get(day);
|
|
2550
2779
|
return {
|
|
2551
2780
|
day,
|
|
2781
|
+
partial: ac != null && !complete.isComplete(day),
|
|
2552
2782
|
sleep_score: sl?.score ?? null,
|
|
2553
2783
|
readiness_score: rd?.score ?? null,
|
|
2554
2784
|
activity_score: ac?.score ?? null,
|
|
@@ -2567,15 +2797,17 @@ function getDaySummary(db, day) {
|
|
|
2567
2797
|
function getTrends(db, days, today) {
|
|
2568
2798
|
const start = shiftDay(today, -(days - 1));
|
|
2569
2799
|
const results = [];
|
|
2800
|
+
const activityEnd = dayCompleteness(db, today).completeThrough(start, today) ?? shiftDay(start, -1);
|
|
2570
2801
|
const metrics = [
|
|
2571
|
-
["Sleep Score", "daily_sleep", "score"],
|
|
2572
|
-
["Readiness", "daily_readiness", "score"],
|
|
2573
|
-
["Activity", "daily_activity", "score"],
|
|
2574
|
-
["Steps", "daily_activity", "steps"],
|
|
2575
|
-
["Active Cal", "daily_activity", "active_calories"]
|
|
2802
|
+
["Sleep Score", "daily_sleep", "score", false],
|
|
2803
|
+
["Readiness", "daily_readiness", "score", false],
|
|
2804
|
+
["Activity", "daily_activity", "score", true],
|
|
2805
|
+
["Steps", "daily_activity", "steps", true],
|
|
2806
|
+
["Active Cal", "daily_activity", "active_calories", true]
|
|
2576
2807
|
];
|
|
2577
|
-
for (const [label, table, col] of metrics) {
|
|
2578
|
-
const
|
|
2808
|
+
for (const [label, table, col, accumulates] of metrics) {
|
|
2809
|
+
const end = accumulates ? activityEnd : today;
|
|
2810
|
+
const row = db.query(`SELECT AVG(${col}) as avg, MIN(${col}) as min, MAX(${col}) as max, COUNT(${col}) as count FROM ${table} WHERE day BETWEEN ? AND ?`).get(start, end);
|
|
2579
2811
|
if (row.count > 0 && row.avg !== null) {
|
|
2580
2812
|
results.push({ label, avg: +row.avg.toFixed(0), min: row.min, max: row.max, count: row.count });
|
|
2581
2813
|
}
|
|
@@ -2726,12 +2958,13 @@ function formatWeekTable(days, format, emptyHint) {
|
|
|
2726
2958
|
}
|
|
2727
2959
|
const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} ${"Stress".padEnd(10)}`;
|
|
2728
2960
|
const sep = source_default.gray("\u2500".repeat(56));
|
|
2729
|
-
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)}`);
|
|
2961
|
+
const rows = days.map((d) => `${padRight(d.partial ? `${d.day}*` : 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)}`);
|
|
2962
|
+
const note = days.some((d) => d.partial) ? [" * still accumulating; its activity totals are not final."] : [];
|
|
2730
2963
|
return [`
|
|
2731
|
-
Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`)].join(`
|
|
2964
|
+
Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`), ...note].join(`
|
|
2732
2965
|
`);
|
|
2733
2966
|
}
|
|
2734
|
-
function formatTrends(trends, days, format) {
|
|
2967
|
+
function formatTrends(trends, days, format, emptyHint) {
|
|
2735
2968
|
if (format === "json")
|
|
2736
2969
|
return JSON.stringify(trends, null, 2);
|
|
2737
2970
|
const lines = [
|
|
@@ -2739,13 +2972,15 @@ function formatTrends(trends, days, format) {
|
|
|
2739
2972
|
source_default.bold(` Trends: last ${days} days`),
|
|
2740
2973
|
source_default.gray("\u2500".repeat(50))
|
|
2741
2974
|
];
|
|
2975
|
+
if (emptyHint && trends.length === 0)
|
|
2976
|
+
lines.push(` No Oura data for the last ${days} days yet.`, ` ${emptyHint}`);
|
|
2742
2977
|
for (const t of trends) {
|
|
2743
2978
|
lines.push(` ${t.label.padEnd(15)} avg: ${String(t.avg).padStart(5)} min: ${String(t.min).padStart(5)} max: ${String(t.max).padStart(5)} (${t.count} days)`);
|
|
2744
2979
|
}
|
|
2745
2980
|
return lines.join(`
|
|
2746
2981
|
`);
|
|
2747
2982
|
}
|
|
2748
|
-
function formatStats(stats, format) {
|
|
2983
|
+
function formatStats(stats, format, emptyHint) {
|
|
2749
2984
|
if (format === "json")
|
|
2750
2985
|
return JSON.stringify(stats, null, 2);
|
|
2751
2986
|
const lines = [
|
|
@@ -2753,6 +2988,10 @@ function formatStats(stats, format) {
|
|
|
2753
2988
|
source_default.bold(" Database Statistics"),
|
|
2754
2989
|
source_default.gray("\u2550".repeat(50))
|
|
2755
2990
|
];
|
|
2991
|
+
if (emptyHint && stats.tables.every((t) => t.rows === 0)) {
|
|
2992
|
+
return [...lines, " No Oura data in the database yet.", ` ${emptyHint}`].join(`
|
|
2993
|
+
`);
|
|
2994
|
+
}
|
|
2756
2995
|
for (const t of stats.tables) {
|
|
2757
2996
|
lines.push(` ${t.table.padEnd(22)} ${String(t.rows).padStart(8)} rows`);
|
|
2758
2997
|
}
|
|
@@ -2788,26 +3027,49 @@ function resolveWindow(opts, today) {
|
|
|
2788
3027
|
}
|
|
2789
3028
|
return { from, to };
|
|
2790
3029
|
}
|
|
2791
|
-
|
|
3030
|
+
var PRUNE_ALL = "all";
|
|
3031
|
+
function resolvePruneScope(value) {
|
|
3032
|
+
if (value === undefined || value === false)
|
|
3033
|
+
return;
|
|
3034
|
+
const raw = value === true ? "" : String(value);
|
|
3035
|
+
const wanted = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
3036
|
+
if (wanted.length === 0 || wanted.some((n) => n.startsWith("-"))) {
|
|
3037
|
+
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.`);
|
|
3038
|
+
}
|
|
3039
|
+
if (wanted.includes(PRUNE_ALL)) {
|
|
3040
|
+
if (wanted.length === 1)
|
|
3041
|
+
return PRUNE_ALL;
|
|
3042
|
+
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.`);
|
|
3043
|
+
}
|
|
3044
|
+
const unknown = wanted.filter((n) => byName(n) === undefined);
|
|
3045
|
+
if (unknown.length > 0) {
|
|
3046
|
+
throw new CliError("BAD_ARGS", `--prune: unknown collection${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.`, `Known collections: ${names().join(", ")}.`);
|
|
3047
|
+
}
|
|
3048
|
+
return [...new Set(wanted)];
|
|
3049
|
+
}
|
|
3050
|
+
async function runSync(ctx, window = {}, options = {}) {
|
|
2792
3051
|
const lines = [];
|
|
2793
3052
|
const log = ctx.format === "table" ? (m) => lines.push(m) : undefined;
|
|
2794
|
-
const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window);
|
|
2795
|
-
const today = getDaySummary(ctx.db, ctx.today);
|
|
3053
|
+
const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window, options);
|
|
3054
|
+
const today = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
|
|
2796
3055
|
return {
|
|
2797
3056
|
json: { import: importResult, today },
|
|
2798
3057
|
text: () => [...lines, formatImportSummary(importResult), formatDaySummary(today, "table")].join(`
|
|
2799
3058
|
`)
|
|
2800
3059
|
};
|
|
2801
3060
|
}
|
|
2802
|
-
var
|
|
3061
|
+
var syncArgs = {
|
|
3062
|
+
from: { type: "string", description: "Re-fetch every collection from this day (YYYY-MM-DD) instead of from its last stored day" },
|
|
3063
|
+
to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" },
|
|
3064
|
+
prune: { type: "string", description: "Apply removals sync kept back: --prune=hr, a list, or --prune=all" }
|
|
3065
|
+
};
|
|
3066
|
+
var syncDef = {
|
|
2803
3067
|
meta: { name: "sync", description: "Import latest data from Oura API and return today's summary" },
|
|
2804
|
-
args:
|
|
2805
|
-
from: { type: "string", description: "Re-fetch every collection from this day (YYYY-MM-DD) instead of from its last stored day" },
|
|
2806
|
-
to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" }
|
|
2807
|
-
},
|
|
3068
|
+
args: syncArgs,
|
|
2808
3069
|
needs: { db: true, client: true },
|
|
2809
|
-
run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today))
|
|
2810
|
-
}
|
|
3070
|
+
run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today), { prune: resolvePruneScope(args.prune) })
|
|
3071
|
+
};
|
|
3072
|
+
var syncCommand = dataCommand(syncDef);
|
|
2811
3073
|
|
|
2812
3074
|
// src/commands/db.ts
|
|
2813
3075
|
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.";
|
|
@@ -2818,7 +3080,7 @@ var dbCommand = defineCommand({
|
|
|
2818
3080
|
meta: { name: "today", description: "Today's summary from local database" },
|
|
2819
3081
|
needs: { db: true },
|
|
2820
3082
|
run(ctx) {
|
|
2821
|
-
const summary = getDaySummary(ctx.db, ctx.today);
|
|
3083
|
+
const summary = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
|
|
2822
3084
|
return { json: summary, text: () => formatDaySummary(summary, "table", SYNC_HINT) };
|
|
2823
3085
|
}
|
|
2824
3086
|
}),
|
|
@@ -2828,7 +3090,7 @@ var dbCommand = defineCommand({
|
|
|
2828
3090
|
needs: { db: true },
|
|
2829
3091
|
run(ctx, args) {
|
|
2830
3092
|
const day = assertCalendarDate(String(args.day), "<day>");
|
|
2831
|
-
const summary = getDaySummary(ctx.db, day);
|
|
3093
|
+
const summary = getDaySummary(ctx.db, day, dayCompleteness(ctx.db, ctx.today));
|
|
2832
3094
|
return { json: summary, text: () => formatDaySummary(summary, "table") };
|
|
2833
3095
|
}
|
|
2834
3096
|
}),
|
|
@@ -2836,7 +3098,8 @@ var dbCommand = defineCommand({
|
|
|
2836
3098
|
meta: { name: "week", description: "Last 7 days from local database" },
|
|
2837
3099
|
needs: { db: true },
|
|
2838
3100
|
run(ctx) {
|
|
2839
|
-
const
|
|
3101
|
+
const complete = dayCompleteness(ctx.db, ctx.today);
|
|
3102
|
+
const days = daysBack(ctx.today, 7).map((d) => getDaySummary(ctx.db, d, complete));
|
|
2840
3103
|
return { json: days, text: () => formatWeekTable(days, "table", "Run `oura-cli sync`, then `oura-cli db week` again.") };
|
|
2841
3104
|
}
|
|
2842
3105
|
}),
|
|
@@ -2847,7 +3110,7 @@ var dbCommand = defineCommand({
|
|
|
2847
3110
|
run(ctx, args) {
|
|
2848
3111
|
const n = args.days === undefined ? 30 : assertPositiveInt(String(args.days), "<days>");
|
|
2849
3112
|
const trends = getTrends(ctx.db, n, ctx.today);
|
|
2850
|
-
return { json: trends, text: () => formatTrends(trends, n, "table") };
|
|
3113
|
+
return { json: trends, text: () => formatTrends(trends, n, "table", "Run `oura-cli sync`, then `oura-cli db trends` again.") };
|
|
2851
3114
|
}
|
|
2852
3115
|
}),
|
|
2853
3116
|
stats: dataCommand({
|
|
@@ -2855,7 +3118,7 @@ var dbCommand = defineCommand({
|
|
|
2855
3118
|
needs: { db: true },
|
|
2856
3119
|
run(ctx) {
|
|
2857
3120
|
const stats = getStats(ctx.db, ctx.today);
|
|
2858
|
-
return { json: stats, text: () => formatStats(stats, "table") };
|
|
3121
|
+
return { json: stats, text: () => formatStats(stats, "table", "Run `oura-cli sync`, then `oura-cli db stats` again.") };
|
|
2859
3122
|
}
|
|
2860
3123
|
})
|
|
2861
3124
|
}
|
|
@@ -2878,9 +3141,8 @@ function getReport(db, days, today) {
|
|
|
2878
3141
|
const prevWeekStart = shiftDay(today, -(days * 2 - 1));
|
|
2879
3142
|
const windowDays = daysBack(today, days);
|
|
2880
3143
|
const lastUpload = db.query("SELECT MAX(timestamp) AS t FROM heartrate").get().t;
|
|
2881
|
-
const
|
|
2882
|
-
const
|
|
2883
|
-
const completeThrough = [...windowDays].reverse().find(isComplete) ?? null;
|
|
3144
|
+
const complete = dayCompleteness(db, today);
|
|
3145
|
+
const completeThrough = complete.completeThrough(weekStart, today);
|
|
2884
3146
|
const activityEnd = completeThrough ?? shiftDay(weekStart, -1);
|
|
2885
3147
|
const dailyRows = [];
|
|
2886
3148
|
for (const d of windowDays) {
|
|
@@ -2894,7 +3156,7 @@ function getReport(db, days, today) {
|
|
|
2894
3156
|
readiness: rd?.score ?? null,
|
|
2895
3157
|
activity: ac?.score ?? null,
|
|
2896
3158
|
steps: ac?.steps ?? null,
|
|
2897
|
-
partial: ac != null && !isComplete(d)
|
|
3159
|
+
partial: ac != null && !complete.isComplete(d)
|
|
2898
3160
|
});
|
|
2899
3161
|
}
|
|
2900
3162
|
const metrics = [
|
|
@@ -2995,8 +3257,8 @@ var RECOMMENDATIONS = {
|
|
|
2995
3257
|
};
|
|
2996
3258
|
function bucketDaysIntoWeeks(days) {
|
|
2997
3259
|
const buckets = [];
|
|
2998
|
-
for (let
|
|
2999
|
-
const chunk = days.slice(
|
|
3260
|
+
for (let end = days.length;end > 0; end -= 7) {
|
|
3261
|
+
const chunk = days.slice(Math.max(0, end - 7), end);
|
|
3000
3262
|
const weekOf = chunk[0].day;
|
|
3001
3263
|
const sleepVals = chunk.map((d) => d.sleep).filter((v) => v !== null);
|
|
3002
3264
|
const readinessVals = chunk.map((d) => d.readiness).filter((v) => v !== null);
|
|
@@ -3004,6 +3266,7 @@ function bucketDaysIntoWeeks(days) {
|
|
|
3004
3266
|
const stepsVals = chunk.map((d) => d.steps).filter((v) => v !== null);
|
|
3005
3267
|
buckets.push({
|
|
3006
3268
|
weekOf,
|
|
3269
|
+
days: chunk.length,
|
|
3007
3270
|
avgSleep: sleepVals.length > 0 ? sleepVals.reduce((a, b) => a + b, 0) / sleepVals.length : null,
|
|
3008
3271
|
avgReadiness: readinessVals.length > 0 ? readinessVals.reduce((a, b) => a + b, 0) / readinessVals.length : null,
|
|
3009
3272
|
avgActivity: activityVals.length > 0 ? activityVals.reduce((a, b) => a + b, 0) / activityVals.length : null,
|
|
@@ -3011,13 +3274,17 @@ function bucketDaysIntoWeeks(days) {
|
|
|
3011
3274
|
partial: chunk.some((d) => d.partial)
|
|
3012
3275
|
});
|
|
3013
3276
|
}
|
|
3014
|
-
return buckets;
|
|
3277
|
+
return buckets.reverse();
|
|
3278
|
+
}
|
|
3279
|
+
function bucketLabel(b) {
|
|
3280
|
+
const stub = b.days < 7 ? ` (${b.days} day${b.days === 1 ? "" : "s"})` : "";
|
|
3281
|
+
return `${b.weekOf}${stub}${b.partial ? "*" : ""}`;
|
|
3015
3282
|
}
|
|
3016
|
-
function partialDayNote(data) {
|
|
3283
|
+
function partialDayNote(data, bucket) {
|
|
3017
3284
|
const partial = data.days.find((d) => d.partial);
|
|
3018
3285
|
if (!partial)
|
|
3019
3286
|
return null;
|
|
3020
|
-
const which = partial.day === data.weekEnd ? "today" : partial.dayLabel;
|
|
3287
|
+
const which = bucket ? `the week of ${bucket.weekOf}` : partial.day === data.weekEnd ? "today" : partial.dayLabel;
|
|
3021
3288
|
const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
|
|
3022
3289
|
return ` * ${which} is still accumulating; activity averages cover ${covers}.`;
|
|
3023
3290
|
}
|
|
@@ -3032,7 +3299,8 @@ function formatReport(data, format, period) {
|
|
|
3032
3299
|
lines.push(source_default.bold(" Oura Monthly Report"));
|
|
3033
3300
|
}
|
|
3034
3301
|
lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
|
|
3035
|
-
const
|
|
3302
|
+
const buckets = period === "month" ? bucketDaysIntoWeeks(data.days) : [];
|
|
3303
|
+
const note = partialDayNote(data, buckets.find((b) => b.partial));
|
|
3036
3304
|
if (note)
|
|
3037
3305
|
lines.push(source_default.yellow(note));
|
|
3038
3306
|
lines.push("");
|
|
@@ -3054,16 +3322,15 @@ function formatReport(data, format, period) {
|
|
|
3054
3322
|
}
|
|
3055
3323
|
lines.push("");
|
|
3056
3324
|
} else {
|
|
3057
|
-
const buckets = bucketDaysIntoWeeks(data.days);
|
|
3058
3325
|
lines.push(source_default.bold(" Last 30 Days:"));
|
|
3059
|
-
lines.push(source_default.gray(" " + "\u2500".repeat(
|
|
3060
|
-
lines.push(` ${"Week of".padEnd(
|
|
3061
|
-
lines.push(source_default.gray(" " + "\u2500".repeat(
|
|
3326
|
+
lines.push(source_default.gray(" " + "\u2500".repeat(69)));
|
|
3327
|
+
lines.push(` ${"Week of".padEnd(21)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`);
|
|
3328
|
+
lines.push(source_default.gray(" " + "\u2500".repeat(69)));
|
|
3062
3329
|
for (const b of buckets) {
|
|
3063
3330
|
const avgSleepInt = b.avgSleep !== null ? Math.round(b.avgSleep) : null;
|
|
3064
3331
|
const avgReadyInt = b.avgReadiness !== null ? Math.round(b.avgReadiness) : null;
|
|
3065
3332
|
const avgActiveInt = b.avgActivity !== null ? Math.round(b.avgActivity) : null;
|
|
3066
|
-
lines.push(` ${(b
|
|
3333
|
+
lines.push(` ${bucketLabel(b).padEnd(21)} ${scoreCell(avgSleepInt, 6)} ${scoreCell(avgReadyInt, 6)} ${scoreCell(avgActiveInt, 7)} ${stepsCell(b.totalSteps, 10)}`);
|
|
3067
3334
|
}
|
|
3068
3335
|
lines.push("");
|
|
3069
3336
|
}
|
|
@@ -3132,7 +3399,7 @@ var reportCommand = dataCommand({
|
|
|
3132
3399
|
// src/commands/healthcheck.ts
|
|
3133
3400
|
function healthcheckCommand(version) {
|
|
3134
3401
|
return defineCommand({
|
|
3135
|
-
meta: { name: "healthcheck", description: "
|
|
3402
|
+
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." },
|
|
3136
3403
|
args: { ...commonArgs },
|
|
3137
3404
|
run({ args }) {
|
|
3138
3405
|
assertKnownArgs(commonArgs, args);
|
|
@@ -3213,8 +3480,18 @@ async function runChecks(deps) {
|
|
|
3213
3480
|
checks.push({ id: "database", status: "fail", detail: msg });
|
|
3214
3481
|
}
|
|
3215
3482
|
if (db) {
|
|
3216
|
-
const
|
|
3217
|
-
|
|
3483
|
+
const damage = quickCheck(db);
|
|
3484
|
+
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." });
|
|
3485
|
+
let last = null;
|
|
3486
|
+
let readFailed;
|
|
3487
|
+
try {
|
|
3488
|
+
last = latestDataDay(db);
|
|
3489
|
+
} catch (err) {
|
|
3490
|
+
readFailed = err instanceof Error ? err.message : String(err);
|
|
3491
|
+
}
|
|
3492
|
+
if (readFailed !== undefined) {
|
|
3493
|
+
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." });
|
|
3494
|
+
} else if (!last) {
|
|
3218
3495
|
checks.push({ id: "data", status: "warn", detail: "No data in the local cache yet.", fix: "oura-cli sync" });
|
|
3219
3496
|
} else {
|
|
3220
3497
|
const ageDays = Math.round((new Date(`${deps.today}T00:00:00Z`).getTime() - new Date(`${last}T00:00:00Z`).getTime()) / 86400000);
|
|
@@ -3225,6 +3502,7 @@ async function runChecks(deps) {
|
|
|
3225
3502
|
}
|
|
3226
3503
|
}
|
|
3227
3504
|
} else {
|
|
3505
|
+
checks.push({ id: "integrity", status: "fail", detail: "Cannot check integrity \u2014 database unavailable." });
|
|
3228
3506
|
checks.push({ id: "data", status: "fail", detail: "Cannot check data \u2014 database unavailable." });
|
|
3229
3507
|
}
|
|
3230
3508
|
db?.close();
|
|
@@ -3233,6 +3511,15 @@ async function runChecks(deps) {
|
|
|
3233
3511
|
const nextStep = checks.find((c) => !settled(c.status))?.fix ?? null;
|
|
3234
3512
|
return { ok, checks, nextStep };
|
|
3235
3513
|
}
|
|
3514
|
+
function quickCheck(db) {
|
|
3515
|
+
try {
|
|
3516
|
+
const rows = db.query("PRAGMA quick_check(1)").all();
|
|
3517
|
+
const first = rows[0] === undefined ? "ok" : Object.values(rows[0])[0] ?? "ok";
|
|
3518
|
+
return first === "ok" ? null : first;
|
|
3519
|
+
} catch (err) {
|
|
3520
|
+
return err instanceof Error ? err.message : String(err);
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3236
3523
|
var DATA_TABLES = ["daily_sleep", "daily_readiness", "daily_activity"];
|
|
3237
3524
|
function latestDataDay(db) {
|
|
3238
3525
|
let latest = null;
|
|
@@ -3373,9 +3660,54 @@ var fetchCommand = dataCommand({
|
|
|
3373
3660
|
}
|
|
3374
3661
|
});
|
|
3375
3662
|
|
|
3663
|
+
// src/commands/registry.ts
|
|
3664
|
+
function buildRegistry(version) {
|
|
3665
|
+
const subCommands = Object.assign(Object.create(null), {
|
|
3666
|
+
login: loginCommand,
|
|
3667
|
+
describe: describeCommand(version, () => subCommands),
|
|
3668
|
+
healthcheck: healthcheckCommand(version),
|
|
3669
|
+
doctor: doctorCommand,
|
|
3670
|
+
manifest: manifestCommand(version, () => subCommands),
|
|
3671
|
+
fetch: fetchCommand,
|
|
3672
|
+
sync: syncCommand,
|
|
3673
|
+
db: dbCommand,
|
|
3674
|
+
report: reportCommand
|
|
3675
|
+
});
|
|
3676
|
+
return subCommands;
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3376
3679
|
// src/lib/citty-error.ts
|
|
3377
3680
|
var ANSI2 = /\u001b\[[0-9;]*m/g;
|
|
3378
|
-
|
|
3681
|
+
var COMMAND_NAME = /^[a-z][a-z0-9-]{0,19}$/;
|
|
3682
|
+
function editDistanceAtMostOne(a, b) {
|
|
3683
|
+
if (Math.abs(a.length - b.length) > 1)
|
|
3684
|
+
return false;
|
|
3685
|
+
let i = 0, j = 0, edits = 0;
|
|
3686
|
+
while (i < a.length && j < b.length) {
|
|
3687
|
+
if (a[i] === b[j]) {
|
|
3688
|
+
i++;
|
|
3689
|
+
j++;
|
|
3690
|
+
continue;
|
|
3691
|
+
}
|
|
3692
|
+
if (++edits > 1)
|
|
3693
|
+
return false;
|
|
3694
|
+
if (a.length > b.length)
|
|
3695
|
+
i++;
|
|
3696
|
+
else if (a.length < b.length)
|
|
3697
|
+
j++;
|
|
3698
|
+
else {
|
|
3699
|
+
i++;
|
|
3700
|
+
j++;
|
|
3701
|
+
}
|
|
3702
|
+
}
|
|
3703
|
+
return edits + (a.length - i) + (b.length - j) <= 1;
|
|
3704
|
+
}
|
|
3705
|
+
function nearestGlobalFlag(token) {
|
|
3706
|
+
if (!token.startsWith("--") || token.length < 4)
|
|
3707
|
+
return;
|
|
3708
|
+
return [...GLOBAL_FLAGS_WITH_VALUE].find((flag) => flag !== token && (editDistanceAtMostOne(flag, token) || flag.startsWith(token)));
|
|
3709
|
+
}
|
|
3710
|
+
function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
|
|
3379
3711
|
const code = err?.code;
|
|
3380
3712
|
if (typeof code !== "string")
|
|
3381
3713
|
return err;
|
|
@@ -3383,6 +3715,14 @@ function fromCittyError(err, removedCommandHints = {}) {
|
|
|
3383
3715
|
switch (code) {
|
|
3384
3716
|
case "E_UNKNOWN_COMMAND": {
|
|
3385
3717
|
const name = message.replace(/^Unknown command\s*/, "").trim();
|
|
3718
|
+
const before = rawArgs[rawArgs.lastIndexOf(name) - 1];
|
|
3719
|
+
const meant = before === undefined ? undefined : nearestGlobalFlag(before);
|
|
3720
|
+
if (meant) {
|
|
3721
|
+
return new CliError("BAD_ARGS", `Unknown flag "${before}".`, `Did you mean ${meant}? Its value was read as a command name.`);
|
|
3722
|
+
}
|
|
3723
|
+
if (!COMMAND_NAME.test(name)) {
|
|
3724
|
+
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.");
|
|
3725
|
+
}
|
|
3386
3726
|
const hint = Object.hasOwn(removedCommandHints, name) ? removedCommandHints[name] : "Run `oura-cli --help` for the list of commands.";
|
|
3387
3727
|
return new CliError("BAD_ARGS", `Unknown command "${name}".`, hint);
|
|
3388
3728
|
}
|
|
@@ -3397,17 +3737,7 @@ function fromCittyError(err, removedCommandHints = {}) {
|
|
|
3397
3737
|
|
|
3398
3738
|
// src/index.ts
|
|
3399
3739
|
var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
3400
|
-
var subCommands =
|
|
3401
|
-
login: loginCommand,
|
|
3402
|
-
describe: describeCommand(VERSION, () => subCommands),
|
|
3403
|
-
healthcheck: healthcheckCommand(VERSION),
|
|
3404
|
-
doctor: doctorCommand,
|
|
3405
|
-
manifest: manifestCommand(VERSION, () => subCommands),
|
|
3406
|
-
fetch: fetchCommand,
|
|
3407
|
-
sync: syncCommand,
|
|
3408
|
-
db: dbCommand,
|
|
3409
|
-
report: reportCommand
|
|
3410
|
-
});
|
|
3740
|
+
var subCommands = buildRegistry(VERSION);
|
|
3411
3741
|
var FETCH_HINT = "The per-collection commands were replaced in 0.5.0 by `oura-cli fetch <collection>`, e.g. `oura-cli fetch sleep --day 2026-09-01`. Run `oura-cli fetch --help`.";
|
|
3412
3742
|
var REMOVED_COMMANDS = {
|
|
3413
3743
|
reset: "`db reset` was removed in 0.5.0; delete the database file (`--db` / OURA_DB_PATH) and run `oura-cli sync` to rebuild it.",
|
|
@@ -3437,7 +3767,7 @@ if (isVersionRequest(rawArgs)) {
|
|
|
3437
3767
|
runMain(main, { rawArgs });
|
|
3438
3768
|
} else {
|
|
3439
3769
|
runCommand(main, { rawArgs }).catch((raw) => {
|
|
3440
|
-
const err = fromCittyError(raw, REMOVED_COMMANDS);
|
|
3770
|
+
const err = fromCittyError(raw, REMOVED_COMMANDS, rawArgs);
|
|
3441
3771
|
emitError(err, formatFromArgv(rawArgs, process.stdout.isTTY === true));
|
|
3442
3772
|
process.exit(exitCodeFor(err));
|
|
3443
3773
|
});
|
package/docs/schemas/doctor.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"required": ["id", "status", "detail"],
|
|
14
14
|
"additionalProperties": false,
|
|
15
15
|
"properties": {
|
|
16
|
-
"id": { "type": "string", "enum": ["token", "token-valid", "database", "data"] },
|
|
16
|
+
"id": { "type": "string", "enum": ["token", "token-valid", "database", "integrity", "data"] },
|
|
17
17
|
"status": { "type": "string", "enum": ["ok", "warn", "fail", "skip"] },
|
|
18
18
|
"detail": { "type": "string" },
|
|
19
19
|
"fix": { "type": "string" }
|
package/package.json
CHANGED