trainbud 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/.env.example +11 -11
  2. package/CHANGELOG.md +757 -675
  3. package/QUICKSTART.md +185 -182
  4. package/README.md +329 -322
  5. package/dist/appDb.d.ts +40 -1
  6. package/dist/appDb.js +120 -51
  7. package/dist/cli.js +47 -0
  8. package/dist/dashboard.js +820 -820
  9. package/dist/dashboardCharts.js +42 -42
  10. package/dist/deviceTokens.d.ts +29 -0
  11. package/dist/deviceTokens.js +49 -0
  12. package/dist/history/context.js +35 -35
  13. package/dist/history/schema.js +66 -66
  14. package/dist/httpServer.js +64 -1
  15. package/dist/pairApi.js +11 -2
  16. package/dist/promptApi.js +5 -5
  17. package/dist/usage.js +17 -17
  18. package/package.json +82 -77
  19. package/dist/appDb.d.ts.map +0 -1
  20. package/dist/appDb.js.map +0 -1
  21. package/dist/check.d.ts.map +0 -1
  22. package/dist/check.js.map +0 -1
  23. package/dist/cli.d.ts.map +0 -1
  24. package/dist/cli.js.map +0 -1
  25. package/dist/config.d.ts.map +0 -1
  26. package/dist/config.js.map +0 -1
  27. package/dist/dashboard.d.ts.map +0 -1
  28. package/dist/dashboard.js.map +0 -1
  29. package/dist/dashboardCharts.d.ts.map +0 -1
  30. package/dist/dashboardCharts.js.map +0 -1
  31. package/dist/dashboardData.d.ts.map +0 -1
  32. package/dist/dashboardData.js.map +0 -1
  33. package/dist/detect/baseline.d.ts.map +0 -1
  34. package/dist/detect/baseline.js.map +0 -1
  35. package/dist/detect/countdown.d.ts.map +0 -1
  36. package/dist/detect/countdown.js.map +0 -1
  37. package/dist/detect/detectors.d.ts.map +0 -1
  38. package/dist/detect/detectors.js.map +0 -1
  39. package/dist/detect/findings.d.ts.map +0 -1
  40. package/dist/detect/findings.js.map +0 -1
  41. package/dist/detect/forecast.d.ts.map +0 -1
  42. package/dist/detect/forecast.js.map +0 -1
  43. package/dist/detect/index.d.ts.map +0 -1
  44. package/dist/detect/index.js.map +0 -1
  45. package/dist/detect/sleepQuality.d.ts.map +0 -1
  46. package/dist/detect/sleepQuality.js.map +0 -1
  47. package/dist/detect/trimp.d.ts.map +0 -1
  48. package/dist/detect/trimp.js.map +0 -1
  49. package/dist/detect/week.d.ts.map +0 -1
  50. package/dist/detect/week.js.map +0 -1
  51. package/dist/garmin/auth.d.ts.map +0 -1
  52. package/dist/garmin/auth.js.map +0 -1
  53. package/dist/garmin/cache.d.ts.map +0 -1
  54. package/dist/garmin/cache.js.map +0 -1
  55. package/dist/garmin/client.d.ts.map +0 -1
  56. package/dist/garmin/client.js.map +0 -1
  57. package/dist/garmin/daily.d.ts.map +0 -1
  58. package/dist/garmin/daily.js.map +0 -1
  59. package/dist/garmin/garminApiTypes.d.ts.map +0 -1
  60. package/dist/garmin/garminApiTypes.js.map +0 -1
  61. package/dist/garmin/garminConnect.d.ts.map +0 -1
  62. package/dist/garmin/garminConnect.js.map +0 -1
  63. package/dist/garmin/partial.d.ts.map +0 -1
  64. package/dist/garmin/partial.js.map +0 -1
  65. package/dist/garmin/rawApi.d.ts.map +0 -1
  66. package/dist/garmin/rawApi.js.map +0 -1
  67. package/dist/garmin/types.d.ts.map +0 -1
  68. package/dist/garmin/types.js.map +0 -1
  69. package/dist/history/capture.d.ts.map +0 -1
  70. package/dist/history/capture.js.map +0 -1
  71. package/dist/history/context.d.ts.map +0 -1
  72. package/dist/history/context.js.map +0 -1
  73. package/dist/history/fallback.d.ts.map +0 -1
  74. package/dist/history/fallback.js.map +0 -1
  75. package/dist/history/ingest.d.ts.map +0 -1
  76. package/dist/history/ingest.js.map +0 -1
  77. package/dist/history/scheduler.d.ts.map +0 -1
  78. package/dist/history/scheduler.js.map +0 -1
  79. package/dist/history/schema.d.ts.map +0 -1
  80. package/dist/history/schema.js.map +0 -1
  81. package/dist/history/store.d.ts.map +0 -1
  82. package/dist/history/store.js.map +0 -1
  83. package/dist/httpServer.d.ts.map +0 -1
  84. package/dist/httpServer.js.map +0 -1
  85. package/dist/index.d.ts.map +0 -1
  86. package/dist/index.js.map +0 -1
  87. package/dist/mcpConfig.d.ts.map +0 -1
  88. package/dist/mcpConfig.js.map +0 -1
  89. package/dist/pairApi.d.ts.map +0 -1
  90. package/dist/pairApi.js.map +0 -1
  91. package/dist/paths.d.ts.map +0 -1
  92. package/dist/paths.js.map +0 -1
  93. package/dist/profile.d.ts.map +0 -1
  94. package/dist/profile.js.map +0 -1
  95. package/dist/promptApi.d.ts.map +0 -1
  96. package/dist/promptApi.js.map +0 -1
  97. package/dist/promptSuggestions.d.ts.map +0 -1
  98. package/dist/promptSuggestions.js.map +0 -1
  99. package/dist/selfTest.d.ts.map +0 -1
  100. package/dist/selfTest.js.map +0 -1
  101. package/dist/server.d.ts.map +0 -1
  102. package/dist/server.js.map +0 -1
  103. package/dist/setup.d.ts.map +0 -1
  104. package/dist/setup.js.map +0 -1
  105. package/dist/toolErrors.d.ts.map +0 -1
  106. package/dist/toolErrors.js.map +0 -1
  107. package/dist/tools/activities.d.ts.map +0 -1
  108. package/dist/tools/activities.js.map +0 -1
  109. package/dist/tools/bodyComposition.d.ts.map +0 -1
  110. package/dist/tools/bodyComposition.js.map +0 -1
  111. package/dist/tools/context.d.ts.map +0 -1
  112. package/dist/tools/context.js.map +0 -1
  113. package/dist/tools/findings.d.ts.map +0 -1
  114. package/dist/tools/findings.js.map +0 -1
  115. package/dist/tools/heartRate.d.ts.map +0 -1
  116. package/dist/tools/heartRate.js.map +0 -1
  117. package/dist/tools/index.d.ts.map +0 -1
  118. package/dist/tools/index.js.map +0 -1
  119. package/dist/tools/payloads.d.ts.map +0 -1
  120. package/dist/tools/payloads.js.map +0 -1
  121. package/dist/tools/recovery.d.ts.map +0 -1
  122. package/dist/tools/recovery.js.map +0 -1
  123. package/dist/tools/sleep.d.ts.map +0 -1
  124. package/dist/tools/sleep.js.map +0 -1
  125. package/dist/tools/stress.d.ts.map +0 -1
  126. package/dist/tools/stress.js.map +0 -1
  127. package/dist/tools/trainingInsights.d.ts.map +0 -1
  128. package/dist/tools/trainingInsights.js.map +0 -1
  129. package/dist/tools/types.d.ts.map +0 -1
  130. package/dist/tools/types.js.map +0 -1
  131. package/dist/tools/vo2Max.d.ts.map +0 -1
  132. package/dist/tools/vo2Max.js.map +0 -1
  133. package/dist/tools/week.d.ts.map +0 -1
  134. package/dist/tools/week.js.map +0 -1
  135. package/dist/usage.d.ts.map +0 -1
  136. package/dist/usage.js.map +0 -1
  137. package/dist/utils/batch.d.ts.map +0 -1
  138. package/dist/utils/batch.js.map +0 -1
  139. package/dist/utils/helpers.d.ts.map +0 -1
  140. package/dist/utils/helpers.js.map +0 -1
  141. package/dist/utils/jsonFile.d.ts.map +0 -1
  142. package/dist/utils/jsonFile.js.map +0 -1
  143. package/dist/utils/logger.d.ts.map +0 -1
  144. package/dist/utils/logger.js.map +0 -1
  145. package/dist/utils/secretFile.d.ts.map +0 -1
  146. package/dist/utils/secretFile.js.map +0 -1
  147. package/dist/utils/stdio.d.ts.map +0 -1
  148. package/dist/utils/stdio.js.map +0 -1
  149. package/dist/version.d.ts.map +0 -1
  150. package/dist/version.js.map +0 -1
  151. package/dist/watchApi.d.ts.map +0 -1
  152. package/dist/watchApi.js.map +0 -1
package/dist/appDb.d.ts CHANGED
@@ -14,7 +14,7 @@ export interface PromptJob {
14
14
  created_at: number;
15
15
  completed_at: number | null;
16
16
  }
17
- export declare const APP_DB_SCHEMA = "\n CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS pair_tokens (\n code TEXT PRIMARY KEY,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n approved_at INTEGER\n );\n CREATE TABLE IF NOT EXISTS prompt_jobs (\n id TEXT PRIMARY KEY,\n prompt TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending',\n result TEXT,\n error TEXT,\n created_at INTEGER NOT NULL,\n completed_at INTEGER\n );\n\n -- Usage. cost_usd is nullable ON PURPOSE: a model this build has no\n -- published price for records its tokens and leaves the cost unknown,\n -- because a call priced at zero is a monthly cap that can never trip.\n -- The schema lives here rather than in usage.ts so that opening the\n -- database does not have to import the module that writes to it.\n CREATE TABLE IF NOT EXISTS ai_usage (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n at INTEGER NOT NULL,\n kind TEXT NOT NULL,\n model TEXT NOT NULL,\n source TEXT NOT NULL,\n input_tokens INTEGER NOT NULL,\n output_tokens INTEGER NOT NULL,\n cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n cache_write_tokens INTEGER NOT NULL DEFAULT 0,\n cost_usd REAL\n );\n CREATE INDEX IF NOT EXISTS ai_usage_at ON ai_usage(at);\n\n CREATE TABLE IF NOT EXISTS feature_usage (\n name TEXT NOT NULL,\n day TEXT NOT NULL,\n count INTEGER NOT NULL,\n PRIMARY KEY (name, day)\n );\n";
17
+ export declare const APP_DB_SCHEMA = "\n CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS pair_tokens (\n code TEXT PRIMARY KEY,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n approved_at INTEGER\n );\n -- One row per paired watch. Only the SHA-256 of the token is kept; see\n -- deviceTokens.ts for why the plaintext is not stored anywhere. Created by\n -- IF NOT EXISTS on every open, so a database written before 0.5.2 gains\n -- the table without a migration step and without touching its other rows.\n CREATE TABLE IF NOT EXISTS device_tokens (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n token_hash TEXT NOT NULL UNIQUE,\n label TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n last_seen_at INTEGER\n );\n\n CREATE TABLE IF NOT EXISTS prompt_jobs (\n id TEXT PRIMARY KEY,\n prompt TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending',\n result TEXT,\n error TEXT,\n created_at INTEGER NOT NULL,\n completed_at INTEGER\n );\n\n -- Usage. cost_usd is nullable ON PURPOSE: a model this build has no\n -- published price for records its tokens and leaves the cost unknown,\n -- because a call priced at zero is a monthly cap that can never trip.\n -- The schema lives here rather than in usage.ts so that opening the\n -- database does not have to import the module that writes to it.\n CREATE TABLE IF NOT EXISTS ai_usage (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n at INTEGER NOT NULL,\n kind TEXT NOT NULL,\n model TEXT NOT NULL,\n source TEXT NOT NULL,\n input_tokens INTEGER NOT NULL,\n output_tokens INTEGER NOT NULL,\n cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n cache_write_tokens INTEGER NOT NULL DEFAULT 0,\n cost_usd REAL\n );\n CREATE INDEX IF NOT EXISTS ai_usage_at ON ai_usage(at);\n\n CREATE TABLE IF NOT EXISTS feature_usage (\n name TEXT NOT NULL,\n day TEXT NOT NULL,\n count INTEGER NOT NULL,\n PRIMARY KEY (name, day)\n );\n";
18
18
  /**
19
19
  * The open handle, for modules that own their own tables.
20
20
  *
@@ -44,6 +44,45 @@ export declare function getPairToken(code: string): PairToken | null;
44
44
  export declare function approvePairToken(code: string): boolean;
45
45
  export declare function deletePairToken(code: string): void;
46
46
  export declare function listPendingPairTokens(): PairToken[];
47
+ export interface DeviceToken {
48
+ id: number;
49
+ label: string;
50
+ created_at: number;
51
+ last_seen_at: number | null;
52
+ }
53
+ /**
54
+ * Mints a token, stores its hash, and returns the plaintext exactly once.
55
+ *
56
+ * There is no way to read it back afterwards, which is the point: the only
57
+ * copy that survives this call is the one the watch stores. A lost token is
58
+ * re-paired, not recovered.
59
+ */
60
+ export declare function createDeviceToken(label: string): {
61
+ id: number;
62
+ token: string;
63
+ };
64
+ /**
65
+ * The id behind a token, or null.
66
+ *
67
+ * The lookup is an indexed equality match on a hash rather than a constant-time
68
+ * comparison, and that is deliberate: timing here can only leak how close a
69
+ * guess came to an existing *hash*, and producing a token that hashes to a
70
+ * known value is the preimage problem. The master key comparison in
71
+ * httpServer.ts is timing-safe because there the secret itself is compared.
72
+ */
73
+ export declare function findDeviceTokenId(token: string): number | null;
74
+ /**
75
+ * Records that a device was seen, at most once a minute.
76
+ *
77
+ * The watch polls, and an unthrottled write here would mean a database write
78
+ * per request for a column nobody reads more precisely than "today".
79
+ */
80
+ export declare function touchDeviceToken(id: number, now?: number): void;
81
+ export declare function listDeviceTokens(): DeviceToken[];
82
+ /** True if a row was actually removed, so the CLI can say so honestly. */
83
+ export declare function revokeDeviceToken(id: number): boolean;
84
+ /** Returns how many were revoked. */
85
+ export declare function revokeAllDeviceTokens(): number;
47
86
  /**
48
87
  * How old an unfinished job has to be before it counts as wreckage.
49
88
  *
package/dist/appDb.js CHANGED
@@ -3,56 +3,69 @@ import { randomInt } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { appConfig } from "./config.js";
6
+ import { generateDeviceToken, hashDeviceToken, looksLikeDeviceToken } from "./deviceTokens.js";
6
7
  import { restrictExistingFile } from "./utils/secretFile.js";
7
8
  // SECTION: App DB — settings, pair tokens, prompt jobs
8
9
  const DB_PATH = path.resolve(path.dirname(appConfig.cachePath), "app.db");
9
10
  let _db = null;
10
- export const APP_DB_SCHEMA = `
11
- CREATE TABLE IF NOT EXISTS settings (
12
- key TEXT PRIMARY KEY,
13
- value TEXT NOT NULL
14
- );
15
- CREATE TABLE IF NOT EXISTS pair_tokens (
16
- code TEXT PRIMARY KEY,
17
- created_at INTEGER NOT NULL,
18
- expires_at INTEGER NOT NULL,
19
- approved_at INTEGER
20
- );
21
- CREATE TABLE IF NOT EXISTS prompt_jobs (
22
- id TEXT PRIMARY KEY,
23
- prompt TEXT NOT NULL,
24
- status TEXT NOT NULL DEFAULT 'pending',
25
- result TEXT,
26
- error TEXT,
27
- created_at INTEGER NOT NULL,
28
- completed_at INTEGER
29
- );
30
-
31
- -- Usage. cost_usd is nullable ON PURPOSE: a model this build has no
32
- -- published price for records its tokens and leaves the cost unknown,
33
- -- because a call priced at zero is a monthly cap that can never trip.
34
- -- The schema lives here rather than in usage.ts so that opening the
35
- -- database does not have to import the module that writes to it.
36
- CREATE TABLE IF NOT EXISTS ai_usage (
37
- id INTEGER PRIMARY KEY AUTOINCREMENT,
38
- at INTEGER NOT NULL,
39
- kind TEXT NOT NULL,
40
- model TEXT NOT NULL,
41
- source TEXT NOT NULL,
42
- input_tokens INTEGER NOT NULL,
43
- output_tokens INTEGER NOT NULL,
44
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
45
- cache_write_tokens INTEGER NOT NULL DEFAULT 0,
46
- cost_usd REAL
47
- );
48
- CREATE INDEX IF NOT EXISTS ai_usage_at ON ai_usage(at);
49
-
50
- CREATE TABLE IF NOT EXISTS feature_usage (
51
- name TEXT NOT NULL,
52
- day TEXT NOT NULL,
53
- count INTEGER NOT NULL,
54
- PRIMARY KEY (name, day)
55
- );
11
+ export const APP_DB_SCHEMA = `
12
+ CREATE TABLE IF NOT EXISTS settings (
13
+ key TEXT PRIMARY KEY,
14
+ value TEXT NOT NULL
15
+ );
16
+ CREATE TABLE IF NOT EXISTS pair_tokens (
17
+ code TEXT PRIMARY KEY,
18
+ created_at INTEGER NOT NULL,
19
+ expires_at INTEGER NOT NULL,
20
+ approved_at INTEGER
21
+ );
22
+ -- One row per paired watch. Only the SHA-256 of the token is kept; see
23
+ -- deviceTokens.ts for why the plaintext is not stored anywhere. Created by
24
+ -- IF NOT EXISTS on every open, so a database written before 0.5.2 gains
25
+ -- the table without a migration step and without touching its other rows.
26
+ CREATE TABLE IF NOT EXISTS device_tokens (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ token_hash TEXT NOT NULL UNIQUE,
29
+ label TEXT NOT NULL,
30
+ created_at INTEGER NOT NULL,
31
+ last_seen_at INTEGER
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS prompt_jobs (
35
+ id TEXT PRIMARY KEY,
36
+ prompt TEXT NOT NULL,
37
+ status TEXT NOT NULL DEFAULT 'pending',
38
+ result TEXT,
39
+ error TEXT,
40
+ created_at INTEGER NOT NULL,
41
+ completed_at INTEGER
42
+ );
43
+
44
+ -- Usage. cost_usd is nullable ON PURPOSE: a model this build has no
45
+ -- published price for records its tokens and leaves the cost unknown,
46
+ -- because a call priced at zero is a monthly cap that can never trip.
47
+ -- The schema lives here rather than in usage.ts so that opening the
48
+ -- database does not have to import the module that writes to it.
49
+ CREATE TABLE IF NOT EXISTS ai_usage (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ at INTEGER NOT NULL,
52
+ kind TEXT NOT NULL,
53
+ model TEXT NOT NULL,
54
+ source TEXT NOT NULL,
55
+ input_tokens INTEGER NOT NULL,
56
+ output_tokens INTEGER NOT NULL,
57
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
58
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
59
+ cost_usd REAL
60
+ );
61
+ CREATE INDEX IF NOT EXISTS ai_usage_at ON ai_usage(at);
62
+
63
+ CREATE TABLE IF NOT EXISTS feature_usage (
64
+ name TEXT NOT NULL,
65
+ day TEXT NOT NULL,
66
+ count INTEGER NOT NULL,
67
+ PRIMARY KEY (name, day)
68
+ );
56
69
  `;
57
70
  function getDb() {
58
71
  if (_db)
@@ -161,6 +174,62 @@ export function listPendingPairTokens() {
161
174
  .prepare("SELECT code, created_at, expires_at, approved_at FROM pair_tokens WHERE expires_at > ? AND approved_at IS NULL ORDER BY created_at DESC")
162
175
  .all(now);
163
176
  }
177
+ /**
178
+ * Mints a token, stores its hash, and returns the plaintext exactly once.
179
+ *
180
+ * There is no way to read it back afterwards, which is the point: the only
181
+ * copy that survives this call is the one the watch stores. A lost token is
182
+ * re-paired, not recovered.
183
+ */
184
+ export function createDeviceToken(label) {
185
+ const token = generateDeviceToken();
186
+ const now = Math.floor(Date.now() / 1000);
187
+ const result = getDb()
188
+ .prepare("INSERT INTO device_tokens (token_hash, label, created_at, last_seen_at) VALUES (?, ?, ?, NULL)")
189
+ .run(hashDeviceToken(token), label, now);
190
+ return { id: Number(result.lastInsertRowid), token };
191
+ }
192
+ /**
193
+ * The id behind a token, or null.
194
+ *
195
+ * The lookup is an indexed equality match on a hash rather than a constant-time
196
+ * comparison, and that is deliberate: timing here can only leak how close a
197
+ * guess came to an existing *hash*, and producing a token that hashes to a
198
+ * known value is the preimage problem. The master key comparison in
199
+ * httpServer.ts is timing-safe because there the secret itself is compared.
200
+ */
201
+ export function findDeviceTokenId(token) {
202
+ if (!looksLikeDeviceToken(token))
203
+ return null;
204
+ const row = getDb()
205
+ .prepare("SELECT id FROM device_tokens WHERE token_hash = ?")
206
+ .get(hashDeviceToken(token));
207
+ return row?.id ?? null;
208
+ }
209
+ /**
210
+ * Records that a device was seen, at most once a minute.
211
+ *
212
+ * The watch polls, and an unthrottled write here would mean a database write
213
+ * per request for a column nobody reads more precisely than "today".
214
+ */
215
+ export function touchDeviceToken(id, now = Math.floor(Date.now() / 1000)) {
216
+ getDb()
217
+ .prepare("UPDATE device_tokens SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)")
218
+ .run(now, id, now - 60);
219
+ }
220
+ export function listDeviceTokens() {
221
+ return getDb()
222
+ .prepare("SELECT id, label, created_at, last_seen_at FROM device_tokens ORDER BY created_at DESC")
223
+ .all();
224
+ }
225
+ /** True if a row was actually removed, so the CLI can say so honestly. */
226
+ export function revokeDeviceToken(id) {
227
+ return getDb().prepare("DELETE FROM device_tokens WHERE id = ?").run(id).changes > 0;
228
+ }
229
+ /** Returns how many were revoked. */
230
+ export function revokeAllDeviceTokens() {
231
+ return getDb().prepare("DELETE FROM device_tokens").run().changes;
232
+ }
164
233
  // Prompt jobs
165
234
  /** Kept long enough to answer "what did it say earlier today", not forever. */
166
235
  const PROMPT_JOB_TTL_SECONDS = 7 * 24 * 60 * 60;
@@ -190,11 +259,11 @@ export const STALE_PROMPT_JOB_SECONDS = 5 * 60;
190
259
  * in flight.
191
260
  */
192
261
  export function reconcileStalePromptJobs(db, now = Math.floor(Date.now() / 1000)) {
193
- db.prepare(`UPDATE prompt_jobs
194
- SET status = 'error',
195
- error = 'The server stopped before this answer came back.',
196
- completed_at = ?
197
- WHERE status IN ('pending', 'running')
262
+ db.prepare(`UPDATE prompt_jobs
263
+ SET status = 'error',
264
+ error = 'The server stopped before this answer came back.',
265
+ completed_at = ?
266
+ WHERE status IN ('pending', 'running')
198
267
  AND created_at < ?`).run(now, now - STALE_PROMPT_JOB_SECONDS);
199
268
  prunePromptJobs(db, now);
200
269
  }
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ import { startHistoryScheduler } from "./history/scheduler.js";
17
17
  import { closeHistoryDb, historyStats, pruneRawPayloads, RAW_RETENTION_DAYS, RAW_REVISIONS_KEPT, } from "./history/store.js";
18
18
  import { describeFindingsCoverage, runDetectors } from "./detect/index.js";
19
19
  import { printLiveCheckResults, runLiveCheck } from "./check.js";
20
+ import { listDeviceTokens, revokeAllDeviceTokens, revokeDeviceToken } from "./appDb.js";
20
21
  // SECTION: Bootstrap
21
22
  //
22
23
  // Runs before every command. Moves pre-0.3.0 state out of `.garmin/` and warns
@@ -440,6 +441,52 @@ export function createCliProgram() {
440
441
  process.exitCode = 1;
441
442
  }
442
443
  });
444
+ // Revoking a watch used to mean rotating TRAINBUD_API_KEY, which logged out
445
+ // the dashboard and every MCP client at the same time -- and, until watch
446
+ // 2.0.2, left the watch holding a dead key it would not replace. A paired
447
+ // watch now carries a token of its own, and this is where it is taken away.
448
+ const devicesCommand = program.command("devices").description("List and revoke paired watches");
449
+ devicesCommand
450
+ .command("list", { isDefault: true })
451
+ .description("Show paired watches, newest first")
452
+ .action(() => {
453
+ const devices = listDeviceTokens();
454
+ if (devices.length === 0) {
455
+ console.log("No paired watches. Pair one from the watch app's setup screen.");
456
+ return;
457
+ }
458
+ for (const device of devices) {
459
+ const created = new Date(device.created_at * 1000).toISOString().slice(0, 10);
460
+ const seen = device.last_seen_at
461
+ ? new Date(device.last_seen_at * 1000).toISOString().slice(0, 16).replace("T", " ")
462
+ : "never";
463
+ console.log(`${String(device.id).padStart(3)} ${device.label} paired ${created} last seen ${seen}`);
464
+ }
465
+ });
466
+ devicesCommand
467
+ .command("revoke [id]")
468
+ .description("Revoke one paired watch, or --all of them")
469
+ .option("--all", "Revoke every paired watch")
470
+ .action((id, options) => {
471
+ if (options.all) {
472
+ const count = revokeAllDeviceTokens();
473
+ console.log(count === 0 ? "Nothing to revoke." : `Revoked ${count} watch${count === 1 ? "" : "es"}.`);
474
+ return;
475
+ }
476
+ const numericId = Number(id);
477
+ if (!id || !Number.isInteger(numericId)) {
478
+ console.error("Pass the id from `trainbud devices list`, or --all.");
479
+ process.exitCode = 1;
480
+ return;
481
+ }
482
+ if (revokeDeviceToken(numericId)) {
483
+ console.log(`Revoked ${numericId}. That watch will show "Watch not authorised" and can pair again.`);
484
+ }
485
+ else {
486
+ console.error(`No paired watch with id ${numericId}.`);
487
+ process.exitCode = 1;
488
+ }
489
+ });
443
490
  program
444
491
  .command("status")
445
492
  .description("Show session and cache status")