hae-vault 0.1.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.
Files changed (160) hide show
  1. package/.env.example +7 -0
  2. package/CLAUDE.md +220 -0
  3. package/README.md +206 -0
  4. package/SKILL.md +60 -0
  5. package/dist/cli/dashboard.d.ts +3 -0
  6. package/dist/cli/dashboard.d.ts.map +1 -0
  7. package/dist/cli/dashboard.js +206 -0
  8. package/dist/cli/dashboard.js.map +1 -0
  9. package/dist/cli/import.d.ts +3 -0
  10. package/dist/cli/import.d.ts.map +1 -0
  11. package/dist/cli/import.js +78 -0
  12. package/dist/cli/import.js.map +1 -0
  13. package/dist/cli/index.d.ts +3 -0
  14. package/dist/cli/index.d.ts.map +1 -0
  15. package/dist/cli/index.js +31 -0
  16. package/dist/cli/index.js.map +1 -0
  17. package/dist/cli/info.d.ts +5 -0
  18. package/dist/cli/info.d.ts.map +1 -0
  19. package/dist/cli/info.js +34 -0
  20. package/dist/cli/info.js.map +1 -0
  21. package/dist/cli/metrics.d.ts +3 -0
  22. package/dist/cli/metrics.d.ts.map +1 -0
  23. package/dist/cli/metrics.js +20 -0
  24. package/dist/cli/metrics.js.map +1 -0
  25. package/dist/cli/query.d.ts +3 -0
  26. package/dist/cli/query.d.ts.map +1 -0
  27. package/dist/cli/query.js +18 -0
  28. package/dist/cli/query.js.map +1 -0
  29. package/dist/cli/serve.d.ts +3 -0
  30. package/dist/cli/serve.d.ts.map +1 -0
  31. package/dist/cli/serve.js +19 -0
  32. package/dist/cli/serve.js.map +1 -0
  33. package/dist/cli/sleep.d.ts +3 -0
  34. package/dist/cli/sleep.d.ts.map +1 -0
  35. package/dist/cli/sleep.js +19 -0
  36. package/dist/cli/sleep.js.map +1 -0
  37. package/dist/cli/summary.d.ts +3 -0
  38. package/dist/cli/summary.d.ts.map +1 -0
  39. package/dist/cli/summary.js +53 -0
  40. package/dist/cli/summary.js.map +1 -0
  41. package/dist/cli/trends.d.ts +3 -0
  42. package/dist/cli/trends.d.ts.map +1 -0
  43. package/dist/cli/trends.js +77 -0
  44. package/dist/cli/trends.js.map +1 -0
  45. package/dist/cli/watch.d.ts +12 -0
  46. package/dist/cli/watch.d.ts.map +1 -0
  47. package/dist/cli/watch.js +89 -0
  48. package/dist/cli/watch.js.map +1 -0
  49. package/dist/cli/workouts.d.ts +3 -0
  50. package/dist/cli/workouts.d.ts.map +1 -0
  51. package/dist/cli/workouts.js +19 -0
  52. package/dist/cli/workouts.js.map +1 -0
  53. package/dist/config.d.ts +9 -0
  54. package/dist/config.d.ts.map +1 -0
  55. package/dist/config.js +25 -0
  56. package/dist/config.js.map +1 -0
  57. package/dist/db/importLog.d.ts +5 -0
  58. package/dist/db/importLog.d.ts.map +1 -0
  59. package/dist/db/importLog.js +10 -0
  60. package/dist/db/importLog.js.map +1 -0
  61. package/dist/db/metrics.d.ts +4 -0
  62. package/dist/db/metrics.d.ts.map +1 -0
  63. package/dist/db/metrics.js +14 -0
  64. package/dist/db/metrics.js.map +1 -0
  65. package/dist/db/schema.d.ts +5 -0
  66. package/dist/db/schema.d.ts.map +1 -0
  67. package/dist/db/schema.js +100 -0
  68. package/dist/db/schema.js.map +1 -0
  69. package/dist/db/sleep.d.ts +4 -0
  70. package/dist/db/sleep.d.ts.map +1 -0
  71. package/dist/db/sleep.js +13 -0
  72. package/dist/db/sleep.js.map +1 -0
  73. package/dist/db/workouts.d.ts +4 -0
  74. package/dist/db/workouts.d.ts.map +1 -0
  75. package/dist/db/workouts.js +11 -0
  76. package/dist/db/workouts.js.map +1 -0
  77. package/dist/index.d.ts +3 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +5 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/parse/metrics.d.ts +17 -0
  82. package/dist/parse/metrics.d.ts.map +1 -0
  83. package/dist/parse/metrics.js +33 -0
  84. package/dist/parse/metrics.js.map +1 -0
  85. package/dist/parse/sleep.d.ts +23 -0
  86. package/dist/parse/sleep.d.ts.map +1 -0
  87. package/dist/parse/sleep.js +58 -0
  88. package/dist/parse/sleep.js.map +1 -0
  89. package/dist/parse/time.d.ts +4 -0
  90. package/dist/parse/time.d.ts.map +1 -0
  91. package/dist/parse/time.js +41 -0
  92. package/dist/parse/time.js.map +1 -0
  93. package/dist/parse/workouts.d.ts +17 -0
  94. package/dist/parse/workouts.d.ts.map +1 -0
  95. package/dist/parse/workouts.js +24 -0
  96. package/dist/parse/workouts.js.map +1 -0
  97. package/dist/server/app.d.ts +5 -0
  98. package/dist/server/app.d.ts.map +1 -0
  99. package/dist/server/app.js +39 -0
  100. package/dist/server/app.js.map +1 -0
  101. package/dist/server/ingest.d.ts +15 -0
  102. package/dist/server/ingest.d.ts.map +1 -0
  103. package/dist/server/ingest.js +41 -0
  104. package/dist/server/ingest.js.map +1 -0
  105. package/dist/types/hae.d.ts +103 -0
  106. package/dist/types/hae.d.ts.map +1 -0
  107. package/dist/types/hae.js +2 -0
  108. package/dist/types/hae.js.map +1 -0
  109. package/dist/util/zip.d.ts +3 -0
  110. package/dist/util/zip.d.ts.map +1 -0
  111. package/dist/util/zip.js +24 -0
  112. package/dist/util/zip.js.map +1 -0
  113. package/docs/COMMANDS.md +315 -0
  114. package/docs/plans/2026-02-18-hae-vault-initial-implementation.md +2015 -0
  115. package/docs/plans/2026-02-18-readme-dashboard-design.md +213 -0
  116. package/docs/plans/2026-02-18-readme-dashboard-plan.md +1306 -0
  117. package/docs/plans/2026-02-18-zip-env-watch-design.md +213 -0
  118. package/docs/plans/2026-02-18-zip-env-watch.md +966 -0
  119. package/package.json +57 -0
  120. package/src/cli/dashboard.ts +242 -0
  121. package/src/cli/import.ts +85 -0
  122. package/src/cli/index.ts +32 -0
  123. package/src/cli/info.ts +36 -0
  124. package/src/cli/metrics.ts +20 -0
  125. package/src/cli/query.ts +17 -0
  126. package/src/cli/serve.ts +18 -0
  127. package/src/cli/sleep.ts +19 -0
  128. package/src/cli/summary.ts +58 -0
  129. package/src/cli/trends.ts +103 -0
  130. package/src/cli/watch.ts +111 -0
  131. package/src/cli/workouts.ts +19 -0
  132. package/src/config.ts +28 -0
  133. package/src/db/importLog.ts +18 -0
  134. package/src/db/metrics.ts +15 -0
  135. package/src/db/schema.ts +105 -0
  136. package/src/db/sleep.ts +15 -0
  137. package/src/db/workouts.ts +13 -0
  138. package/src/index.ts +4 -0
  139. package/src/parse/metrics.ts +50 -0
  140. package/src/parse/sleep.ts +82 -0
  141. package/src/parse/time.ts +43 -0
  142. package/src/parse/workouts.ts +42 -0
  143. package/src/server/app.ts +46 -0
  144. package/src/server/ingest.ts +68 -0
  145. package/src/types/hae.ts +94 -0
  146. package/src/util/zip.ts +24 -0
  147. package/tests/cli-watch.test.ts +64 -0
  148. package/tests/db-import-log.test.ts +40 -0
  149. package/tests/db-metrics.test.ts +44 -0
  150. package/tests/db-schema.test.ts +55 -0
  151. package/tests/db-sleep.test.ts +36 -0
  152. package/tests/db-workouts.test.ts +34 -0
  153. package/tests/ingest.test.ts +99 -0
  154. package/tests/parse-metrics.test.ts +55 -0
  155. package/tests/parse-sleep.test.ts +65 -0
  156. package/tests/parse-time.test.ts +48 -0
  157. package/tests/parse-workouts.test.ts +43 -0
  158. package/tests/types.test.ts +27 -0
  159. package/tests/util-zip.test.ts +46 -0
  160. package/tsconfig.json +19 -0
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "hae-vault",
3
+ "version": "0.1.0",
4
+ "description": "CLI + HTTP server for Apple Health data from Health Auto Export",
5
+ "type": "module",
6
+ "bin": {
7
+ "hvault": "./dist/index.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "scripts": {
11
+ "dev": "tsx src/index.ts",
12
+ "build": "tsc",
13
+ "start": "node dist/index.js",
14
+ "prepare": "npm run build",
15
+ "test": "tsx --test tests/**/*.test.ts"
16
+ },
17
+ "engines": {
18
+ "node": ">=22.0.0"
19
+ },
20
+ "keywords": [
21
+ "apple-health",
22
+ "health-auto-export",
23
+ "sqlite",
24
+ "cli",
25
+ "hvault",
26
+ "sleep",
27
+ "hrv",
28
+ "steps",
29
+ "fitness",
30
+ "hae"
31
+ ],
32
+ "author": "Ruben Khachaturov <mr.kha4a2rov@protonmail.com>",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/mrkhachaturov/hae-vault.git"
37
+ },
38
+ "homepage": "https://github.com/mrkhachaturov/hae-vault#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/mrkhachaturov/hae-vault/issues"
41
+ },
42
+ "dependencies": {
43
+ "adm-zip": "^0.5.16",
44
+ "better-sqlite3": "^12.6.2",
45
+ "commander": "^12.1.0",
46
+ "dotenv": "^17.3.1",
47
+ "express": "^4.21.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/adm-zip": "^0.5.7",
51
+ "@types/better-sqlite3": "^7.6.11",
52
+ "@types/express": "^4.17.21",
53
+ "@types/node": "^22.10.0",
54
+ "tsx": "^4.19.0",
55
+ "typescript": "^5.7.0"
56
+ }
57
+ }
@@ -0,0 +1,242 @@
1
+ import { Command } from 'commander';
2
+ import { openDb } from '../db/schema.js';
3
+
4
+ // ── helpers ──────────────────────────────────────────────────────────────────
5
+
6
+ function pad(s: string, width: number): string {
7
+ return s.padEnd(width);
8
+ }
9
+
10
+ function fmt1(n: number | null | undefined): string {
11
+ if (n == null) return '—';
12
+ return n.toFixed(1);
13
+ }
14
+
15
+ function fmtInt(n: number | null | undefined): string {
16
+ if (n == null) return '—';
17
+ return Math.round(n).toLocaleString();
18
+ }
19
+
20
+ function sec2min(s: number | null | undefined): string {
21
+ if (s == null) return '—';
22
+ return `${Math.round(s / 60)}min`;
23
+ }
24
+
25
+ function kj2kcal(kj: number | null | undefined): string {
26
+ if (kj == null) return '—';
27
+ return `${Math.round(kj / 4.184)} kcal`;
28
+ }
29
+
30
+ function arrow(first: number, last: number): string {
31
+ const delta = last - first;
32
+ if (Math.abs(delta) < 0.01 * Math.abs(first || 1)) return '→';
33
+ return delta > 0 ? '↑' : '↓';
34
+ }
35
+
36
+ function ruler(label: string, width = 43): string {
37
+ const inner = `── ${label} `;
38
+ return inner + '─'.repeat(Math.max(0, width - inner.length));
39
+ }
40
+
41
+ function workoutEmoji(name: string): string {
42
+ const n = name.toLowerCase();
43
+ if (n.includes('run')) return '🏃';
44
+ if (n.includes('walk')) return '🚶';
45
+ if (n.includes('cycl') || n.includes('bike')) return '🚴';
46
+ if (n.includes('swim')) return '🏊';
47
+ if (n.includes('yoga')) return '🧘';
48
+ if (n.includes('strength') || n.includes('weight') || n.includes('lift')) return '🏋️';
49
+ if (n.includes('hike')) return '🥾';
50
+ return '🏅';
51
+ }
52
+
53
+ function latestMetric(
54
+ db: ReturnType<typeof openDb>,
55
+ metricName: string,
56
+ days = 7
57
+ ): number | null {
58
+ const since = new Date();
59
+ since.setDate(since.getDate() - days);
60
+ const row = db.prepare(
61
+ `SELECT qty FROM metrics WHERE metric = ? AND date >= ? AND qty IS NOT NULL ORDER BY ts DESC LIMIT 1`
62
+ ).get(metricName, since.toISOString().slice(0, 10)) as { qty: number } | undefined;
63
+ return row?.qty ?? null;
64
+ }
65
+
66
+ function dailyAvgs(
67
+ db: ReturnType<typeof openDb>,
68
+ metricName: string,
69
+ days: number
70
+ ): number[] {
71
+ const since = new Date();
72
+ since.setDate(since.getDate() - days);
73
+ const rows = db.prepare(
74
+ `SELECT date, AVG(qty) as avg_qty FROM metrics
75
+ WHERE metric = ? AND date >= ? AND qty IS NOT NULL
76
+ GROUP BY date ORDER BY date ASC`
77
+ ).all(metricName, since.toISOString().slice(0, 10)) as { date: string; avg_qty: number }[];
78
+ return rows.map(r => r.avg_qty);
79
+ }
80
+
81
+ function sleepDailyAvgs(
82
+ db: ReturnType<typeof openDb>,
83
+ days: number
84
+ ): number[] {
85
+ const since = new Date();
86
+ since.setDate(since.getDate() - days);
87
+ const rows = db.prepare(
88
+ `SELECT asleep_h FROM sleep WHERE date >= ? AND asleep_h IS NOT NULL ORDER BY date ASC`
89
+ ).all(since.toISOString().slice(0, 10)) as { asleep_h: number }[];
90
+ return rows.map(r => r.asleep_h);
91
+ }
92
+
93
+ function trendLine(
94
+ values: number[],
95
+ label: string,
96
+ unit: string,
97
+ round = false
98
+ ): string {
99
+ if (values.length < 2) return '';
100
+ const avg = values.reduce((a, b) => a + b, 0) / values.length;
101
+ const first = values[0];
102
+ const last = values[values.length - 1];
103
+ const dir = arrow(first, last);
104
+ const fmt = round ? fmtInt : fmt1;
105
+ return ` ${pad(label + ':', 14)} ${fmt(first)} → ${fmt(last)}${unit} ${dir} (avg ${fmt(avg)})`;
106
+ }
107
+
108
+ // ── command ───────────────────────────────────────────────────────────────────
109
+
110
+ export const dashboardCommand = new Command('dashboard')
111
+ .description('Terminal dashboard: sleep, activity, heart health, workouts, trends')
112
+ .option('--days <n>', 'Trend window in days', '7')
113
+ .option('--json', 'Output raw JSON')
114
+ .action((opts) => {
115
+ const db = openDb();
116
+ const trendDays = parseInt(opts.days, 10);
117
+ const today = new Date().toISOString().slice(0, 10);
118
+
119
+ // ── sleep (last night) ─────────────────────────────────────────────────
120
+ const sleep = db.prepare(
121
+ `SELECT * FROM sleep ORDER BY date DESC LIMIT 1`
122
+ ).get() as {
123
+ date: string; asleep_h: number | null; in_bed_h: number | null;
124
+ deep_h: number | null; rem_h: number | null; awake_h: number | null;
125
+ source: string | null;
126
+ } | undefined;
127
+
128
+ // ── activity (today, fallback last 2 days) ─────────────────────────────
129
+ const steps = latestMetric(db, 'step_count', 2);
130
+ const activeCal = latestMetric(db, 'active_energy_burned', 2);
131
+ const standHours = latestMetric(db, 'apple_stand_hour', 2);
132
+
133
+ // ── heart health (last 7 days) ─────────────────────────────────────────
134
+ const restingHR = latestMetric(db, 'resting_heart_rate', 7);
135
+ const hrv = latestMetric(db, 'heart_rate_variability_sdnn', 7);
136
+
137
+ // ── recent workouts ────────────────────────────────────────────────────
138
+ const workouts = db.prepare(
139
+ `SELECT date, name, duration_s, calories_kj, avg_hr FROM workouts ORDER BY ts DESC LIMIT 5`
140
+ ).all() as { date: string; name: string; duration_s: number | null; calories_kj: number | null; avg_hr: number | null }[];
141
+
142
+ // ── trends ────────────────────────────────────────────────────────────
143
+ const stepTrend = dailyAvgs(db, 'step_count', trendDays);
144
+ const hrTrend = dailyAvgs(db, 'resting_heart_rate', trendDays);
145
+ const hrvTrend = dailyAvgs(db, 'heart_rate_variability_sdnn', trendDays);
146
+ const sleepTrend = sleepDailyAvgs(db, trendDays);
147
+
148
+ // ── vault stats ───────────────────────────────────────────────────────
149
+ const metricsCount = (db.prepare('SELECT COUNT(*) as c FROM metrics').get() as { c: number }).c;
150
+ const sleepCount = (db.prepare('SELECT COUNT(*) as c FROM sleep').get() as { c: number }).c;
151
+ const workoutsCount = (db.prepare('SELECT COUNT(*) as c FROM workouts').get() as { c: number }).c;
152
+ const lastSync = db.prepare('SELECT received_at FROM sync_log ORDER BY received_at DESC LIMIT 1').get() as { received_at: string } | undefined;
153
+
154
+ if (opts.json) {
155
+ console.log(JSON.stringify({
156
+ date: today,
157
+ sleep: sleep ?? null,
158
+ activity: { steps, activeCal, standHours },
159
+ heartHealth: { restingHR, hrv },
160
+ workouts,
161
+ trends: { steps: stepTrend, restingHR: hrTrend, hrv: hrvTrend, sleep: sleepTrend },
162
+ vault: { metricsCount, sleepCount, workoutsCount, lastSync: lastSync?.received_at ?? null }
163
+ }, null, 2));
164
+ return;
165
+ }
166
+
167
+ const lines: string[] = [];
168
+ lines.push(`📅 ${today} | Apple Health Vault`);
169
+ lines.push('');
170
+
171
+ // sleep
172
+ lines.push(ruler('Sleep (last night)'));
173
+ if (sleep) {
174
+ const eff = sleep.in_bed_h && sleep.asleep_h
175
+ ? Math.round((sleep.asleep_h / sleep.in_bed_h) * 100) : null;
176
+ lines.push(`😴 ${fmt1(sleep.asleep_h)}h | Efficiency: ${eff != null ? eff + '%' : '—'}`);
177
+ const light = (sleep.asleep_h ?? 0) - (sleep.deep_h ?? 0) - (sleep.rem_h ?? 0);
178
+ const deepPct = sleep.asleep_h ? Math.round(((sleep.deep_h ?? 0) / sleep.asleep_h) * 100) : 0;
179
+ const remPct = sleep.asleep_h ? Math.round(((sleep.rem_h ?? 0) / sleep.asleep_h) * 100) : 0;
180
+ const lightPct = sleep.asleep_h ? Math.round((light / sleep.asleep_h) * 100) : 0;
181
+ lines.push(` Deep: ${fmt1(sleep.deep_h)}h (${deepPct}%) | REM: ${fmt1(sleep.rem_h)}h (${remPct}%) | Light: ${fmt1(light)}h (${lightPct}%)`);
182
+ lines.push(` Awake: ${fmt1(sleep.awake_h)}h | Source: ${sleep.source ?? '—'}`);
183
+ } else {
184
+ lines.push(' No sleep data');
185
+ }
186
+ lines.push('');
187
+
188
+ // activity
189
+ lines.push(ruler('Activity (recent)'));
190
+ const actParts: string[] = [];
191
+ if (steps != null) actParts.push(`👟 ${fmtInt(steps)} steps`);
192
+ if (activeCal != null) actParts.push(`🔥 ${fmtInt(activeCal)} kcal active`);
193
+ if (actParts.length > 0) {
194
+ lines.push(actParts.join(' | '));
195
+ if (standHours != null) lines.push(` Stand hours: ${Math.round(standHours)}`);
196
+ } else {
197
+ lines.push(' No activity data');
198
+ }
199
+ lines.push('');
200
+
201
+ // heart health
202
+ lines.push(ruler('Heart Health'));
203
+ const hh: string[] = [];
204
+ if (restingHR != null) hh.push(`💓 Resting HR: ${Math.round(restingHR)}bpm`);
205
+ if (hrv != null) hh.push(`HRV: ${Math.round(hrv)}ms`);
206
+ lines.push(hh.length > 0 ? hh.join(' | ') : ' No heart data');
207
+ lines.push('');
208
+
209
+ // workouts
210
+ lines.push(ruler('Recent Workouts'));
211
+ if (workouts.length > 0) {
212
+ for (const w of workouts) {
213
+ const emoji = workoutEmoji(w.name);
214
+ const namePadded = pad(w.name, 18);
215
+ const dur = sec2min(w.duration_s);
216
+ const cal = kj2kcal(w.calories_kj);
217
+ lines.push(`${emoji} ${w.date} ${namePadded} ${dur} ${cal}`);
218
+ }
219
+ } else {
220
+ lines.push(' No workout data');
221
+ }
222
+ lines.push('');
223
+
224
+ // trends
225
+ lines.push(ruler(`${trendDays}-Day Trends`));
226
+ const tl: string[] = [
227
+ trendLine(stepTrend, 'Steps', '', true),
228
+ trendLine(sleepTrend, 'Sleep', 'h'),
229
+ trendLine(hrTrend, 'Resting HR', 'bpm', true),
230
+ trendLine(hrvTrend, 'HRV', 'ms', true),
231
+ ].filter(Boolean);
232
+ if (tl.length > 0) lines.push(...tl);
233
+ else lines.push(' Insufficient data for trends');
234
+ lines.push('');
235
+
236
+ // vault stats
237
+ lines.push(ruler('Vault Stats'));
238
+ lines.push(` Metrics: ${metricsCount.toLocaleString()} | Sleep: ${sleepCount} | Workouts: ${workoutsCount}`);
239
+ lines.push(` Last sync: ${lastSync?.received_at ? new Date(lastSync.received_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : 'never'}`);
240
+
241
+ console.log(lines.join('\n'));
242
+ });
@@ -0,0 +1,85 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { openDb } from '../db/schema.js';
5
+ import { ingest } from '../server/ingest.js';
6
+ import { hasBeenImported, logImport } from '../db/importLog.js';
7
+ import { extractPayloadFromZip } from '../util/zip.js';
8
+ import { config } from '../config.js';
9
+ import type { HaePayload } from '../types/hae.js';
10
+
11
+ function sha256(buf: Buffer): string {
12
+ return createHash('sha256').update(buf).digest('hex');
13
+ }
14
+
15
+ function loadFile(file: string): { payload: HaePayload; hash: string } {
16
+ let buf: Buffer;
17
+ try {
18
+ buf = readFileSync(file);
19
+ } catch (err) {
20
+ console.error(JSON.stringify({ error: `Cannot read file: ${String(err)}` }));
21
+ process.exit(1);
22
+ }
23
+
24
+ const hash = sha256(buf);
25
+
26
+ let payload: HaePayload | null;
27
+ if (file.toLowerCase().endsWith('.zip')) {
28
+ payload = extractPayloadFromZip(buf);
29
+ if (!payload) {
30
+ console.error(JSON.stringify({ error: 'No valid HealthAutoExport-*.json found in zip' }));
31
+ process.exit(1);
32
+ }
33
+ } else {
34
+ try {
35
+ payload = JSON.parse(buf.toString('utf-8')) as HaePayload;
36
+ } catch (err) {
37
+ console.error(JSON.stringify({ error: `Invalid JSON: ${String(err)}` }));
38
+ process.exit(1);
39
+ }
40
+ if (!payload?.data) {
41
+ console.error(JSON.stringify({ error: 'Missing data field — not a valid HAE export' }));
42
+ process.exit(1);
43
+ }
44
+ }
45
+
46
+ return { payload, hash };
47
+ }
48
+
49
+ export const importCommand = new Command('import')
50
+ .description('Import a Health Auto Export JSON or ZIP file into the database')
51
+ .argument('<file>', 'Path to the HAE JSON or ZIP export file')
52
+ .option('--target <name>', 'Target name (device/person identifier)', config.target)
53
+ .option('--pretty', 'Pretty-print summary JSON', false)
54
+ .action((file: string, opts) => {
55
+ const db = openDb(config.dbPath);
56
+ const { payload, hash } = loadFile(file);
57
+
58
+ if (hasBeenImported(db, hash)) {
59
+ const result = { skipped: true, reason: 'already imported', file, hash };
60
+ console.log(opts.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
61
+ return;
62
+ }
63
+
64
+ const ingestResult = ingest(db, payload, {
65
+ target: opts.target,
66
+ sessionId: null,
67
+ automationName: 'file-import',
68
+ automationPeriod: 'manual',
69
+ });
70
+
71
+ logImport(db, file, hash, ingestResult);
72
+
73
+ const result = {
74
+ ok: true,
75
+ file,
76
+ target: opts.target,
77
+ hash,
78
+ added: {
79
+ metrics: ingestResult.metricsAdded,
80
+ sleep: ingestResult.sleepAdded,
81
+ workouts: ingestResult.workoutsAdded,
82
+ },
83
+ };
84
+ console.log(opts.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
85
+ });
@@ -0,0 +1,32 @@
1
+ import { Command } from 'commander';
2
+ import { serveCommand } from './serve.js';
3
+ import { metricsCommand } from './metrics.js';
4
+ import { sleepCommand } from './sleep.js';
5
+ import { workoutsCommand } from './workouts.js';
6
+ import { summaryCommand } from './summary.js';
7
+ import { queryCommand } from './query.js';
8
+ import { sourcesCommand, lastSyncCommand, statsCommand } from './info.js';
9
+ import { importCommand } from './import.js';
10
+ import { watchCommand } from './watch.js';
11
+ import { dashboardCommand } from './dashboard.js';
12
+ import { trendsCommand } from './trends.js';
13
+
14
+ export const program = new Command();
15
+ program
16
+ .name('hvault')
17
+ .description('Apple Health data vault — ingest + query')
18
+ .version('0.1.0');
19
+
20
+ program.addCommand(serveCommand);
21
+ program.addCommand(importCommand);
22
+ program.addCommand(watchCommand);
23
+ program.addCommand(metricsCommand);
24
+ program.addCommand(sleepCommand);
25
+ program.addCommand(workoutsCommand);
26
+ program.addCommand(summaryCommand);
27
+ program.addCommand(queryCommand);
28
+ program.addCommand(dashboardCommand);
29
+ program.addCommand(trendsCommand);
30
+ program.addCommand(sourcesCommand);
31
+ program.addCommand(lastSyncCommand);
32
+ program.addCommand(statsCommand);
@@ -0,0 +1,36 @@
1
+ import { Command } from 'commander';
2
+ import { openDb } from '../db/schema.js';
3
+
4
+ export const sourcesCommand = new Command('sources')
5
+ .description('Show what metrics are in the DB and their date coverage')
6
+ .option('--pretty', 'Pretty-print JSON', false)
7
+ .action((opts) => {
8
+ const db = openDb();
9
+ const rows = db.prepare(`
10
+ SELECT metric, units, COUNT(*) as count, MIN(date) as first_date, MAX(date) as last_date
11
+ FROM metrics GROUP BY metric, units ORDER BY metric
12
+ `).all();
13
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
14
+ });
15
+
16
+ export const lastSyncCommand = new Command('last-sync')
17
+ .description('Show when the last HAE push was received')
18
+ .option('--pretty', 'Pretty-print JSON', false)
19
+ .action((opts) => {
20
+ const db = openDb();
21
+ const row = db.prepare(`SELECT * FROM sync_log ORDER BY received_at DESC LIMIT 1`).get() ?? null;
22
+ console.log(opts.pretty ? JSON.stringify(row, null, 2) : JSON.stringify(row));
23
+ });
24
+
25
+ export const statsCommand = new Command('stats')
26
+ .description('Show row counts per table')
27
+ .option('--pretty', 'Pretty-print JSON', false)
28
+ .action((opts) => {
29
+ const db = openDb();
30
+ const metrics = (db.prepare('SELECT COUNT(*) as count FROM metrics').get() as { count: number }).count;
31
+ const sleep = (db.prepare('SELECT COUNT(*) as count FROM sleep').get() as { count: number }).count;
32
+ const workouts = (db.prepare('SELECT COUNT(*) as count FROM workouts').get() as { count: number }).count;
33
+ const syncs = (db.prepare('SELECT COUNT(*) as count FROM sync_log').get() as { count: number }).count;
34
+ const result = { metrics, sleep, workouts, syncs };
35
+ console.log(opts.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
36
+ });
@@ -0,0 +1,20 @@
1
+ import { Command } from 'commander';
2
+ import { openDb } from '../db/schema.js';
3
+
4
+ export const metricsCommand = new Command('metrics')
5
+ .description('Query health metrics')
6
+ .requiredOption('--metric <name>', 'Metric name (e.g. step_count, heart_rate)')
7
+ .option('--days <n>', 'Last N days', '30')
8
+ .option('--pretty', 'Pretty-print JSON', false)
9
+ .action((opts) => {
10
+ const db = openDb();
11
+ const since = new Date();
12
+ since.setDate(since.getDate() - parseInt(opts.days, 10));
13
+ const rows = db.prepare(`
14
+ SELECT ts, date, qty, min, avg, max, units, source, target
15
+ FROM metrics
16
+ WHERE metric = ? AND date >= ?
17
+ ORDER BY ts ASC
18
+ `).all(opts.metric, since.toISOString().slice(0, 10));
19
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
20
+ });
@@ -0,0 +1,17 @@
1
+ import { Command } from 'commander';
2
+ import { openDb } from '../db/schema.js';
3
+
4
+ export const queryCommand = new Command('query')
5
+ .description('Run raw SQL against the health database (returns JSON)')
6
+ .argument('<sql>', 'SQL query to run')
7
+ .option('--pretty', 'Pretty-print JSON', false)
8
+ .action((sql: string, opts) => {
9
+ const db = openDb();
10
+ try {
11
+ const rows = db.prepare(sql).all();
12
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
13
+ } catch (err) {
14
+ console.error(JSON.stringify({ error: String(err) }));
15
+ process.exit(1);
16
+ }
17
+ });
@@ -0,0 +1,18 @@
1
+ import { Command } from 'commander';
2
+ import { createApp } from '../server/app.js';
3
+ import { openDb } from '../db/schema.js';
4
+ import { config } from '../config.js';
5
+
6
+ export const serveCommand = new Command('serve')
7
+ .description('Start HTTP server to receive Health Auto Export pushes')
8
+ .option('-p, --port <number>', 'Port to listen on', String(config.port))
9
+ .option('--token <secret>', 'Require Authorization: Bearer <secret>', config.token)
10
+ .action((opts) => {
11
+ const db = openDb(config.dbPath);
12
+ const app = createApp(db, { token: opts.token });
13
+ const port = parseInt(opts.port, 10);
14
+ app.listen(port, () => {
15
+ console.log(`hvault server listening on http://0.0.0.0:${port}/api/ingest`);
16
+ if (opts.token) console.log('Auth: Bearer token required');
17
+ });
18
+ });
@@ -0,0 +1,19 @@
1
+ import { Command } from 'commander';
2
+ import { openDb } from '../db/schema.js';
3
+
4
+ export const sleepCommand = new Command('sleep')
5
+ .description('Query sleep data')
6
+ .option('--days <n>', 'Last N days', '14')
7
+ .option('--pretty', 'Pretty-print JSON', false)
8
+ .action((opts) => {
9
+ const db = openDb();
10
+ const since = new Date();
11
+ since.setDate(since.getDate() - parseInt(opts.days, 10));
12
+ const rows = db.prepare(`
13
+ SELECT date, sleep_start, sleep_end, core_h, deep_h, rem_h, awake_h, asleep_h, in_bed_h, schema_ver, source
14
+ FROM sleep
15
+ WHERE date >= ?
16
+ ORDER BY date ASC
17
+ `).all(since.toISOString().slice(0, 10));
18
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
19
+ });
@@ -0,0 +1,58 @@
1
+ import { Command } from 'commander';
2
+ import { openDb } from '../db/schema.js';
3
+
4
+ export const summaryCommand = new Command('summary')
5
+ .description('Summarise metrics (averages) over N days')
6
+ .option('--days <n>', 'Last N days', '90')
7
+ .option('--pretty', 'Pretty-print JSON', false)
8
+ .option('-c, --color', 'Pretty terminal output with emoji indicators', false)
9
+ .action((opts) => {
10
+ const db = openDb();
11
+ const since = new Date();
12
+ since.setDate(since.getDate() - parseInt(opts.days, 10));
13
+ const sinceStr = since.toISOString().slice(0, 10);
14
+
15
+ if (opts.color) {
16
+ const days = parseInt(opts.days, 10);
17
+
18
+ function avgMetric(metricName: string): number | null {
19
+ const row = db.prepare(
20
+ `SELECT AVG(qty) as avg FROM metrics WHERE metric = ? AND date >= ? AND qty IS NOT NULL`
21
+ ).get(metricName, sinceStr) as { avg: number | null } | undefined;
22
+ return row?.avg ?? null;
23
+ }
24
+
25
+ const steps = avgMetric('step_count');
26
+ const restingHR = avgMetric('resting_heart_rate');
27
+ const hrv = avgMetric('heart_rate_variability_sdnn');
28
+ const activeCal = avgMetric('active_energy_burned');
29
+
30
+ const sleepRow = db.prepare(
31
+ `SELECT AVG(asleep_h) as avg FROM sleep WHERE date >= ? AND asleep_h IS NOT NULL`
32
+ ).get(sinceStr) as { avg: number | null } | undefined;
33
+ const sleep = sleepRow?.avg ?? null;
34
+
35
+ const lines: string[] = [`📊 ${days}-Day Summary`, ''];
36
+ if (steps != null) lines.push(`👟 Avg Steps: ${Math.round(steps).toLocaleString()}`);
37
+ if (restingHR != null) lines.push(`💓 Avg Resting HR: ${Math.round(restingHR)}bpm`);
38
+ if (hrv != null) lines.push(`🧠 Avg HRV: ${Math.round(hrv)}ms`);
39
+ if (sleep != null) lines.push(`😴 Avg Sleep: ${sleep.toFixed(1)}h`);
40
+ if (activeCal != null) lines.push(`🔥 Avg Active Cal: ${Math.round(activeCal).toLocaleString()} kcal`);
41
+
42
+ if (lines.length === 2) lines.push(' No summary data available');
43
+ console.log(lines.join('\n'));
44
+ return;
45
+ }
46
+
47
+ const rows = db.prepare(`
48
+ SELECT metric, units,
49
+ AVG(qty) as avg_qty, MIN(qty) as min_qty, MAX(qty) as max_qty,
50
+ COUNT(*) as count,
51
+ MIN(date) as first_date, MAX(date) as last_date
52
+ FROM metrics
53
+ WHERE date >= ? AND qty IS NOT NULL
54
+ GROUP BY metric, units
55
+ ORDER BY metric ASC
56
+ `).all(sinceStr);
57
+ console.log(opts.pretty ? JSON.stringify(rows, null, 2) : JSON.stringify(rows));
58
+ });