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
@@ -0,0 +1,966 @@
1
+ # ZIP + Env Config + Watch + Dedup Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add ZIP import support, full env/Docker config, `hvault watch` polling command, and SHA-256 deduplication to the existing hae-vault codebase.
6
+
7
+ **Architecture:** New `src/config.ts` singleton loads dotenv then exports all config values; CLI commands read defaults from `config` and let flags override. Import dedup lives in `src/db/importLog.ts` (testable pure DB functions). ZIP extraction lives in `src/util/zip.ts` (shared by `import` and `watch`). `ingest()` gets a return type so counts can flow into `import_log`.
8
+
9
+ **Tech Stack:** adm-zip (sync ZIP extraction), dotenv (env file loading), node:crypto (SHA-256), node:test + node:assert (existing test runner)
10
+
11
+ **Start state:** 44 tests passing, `npm test` is green. Keep it green after every task.
12
+
13
+ ---
14
+
15
+ ## Task 1: Install dependencies
16
+
17
+ **Files:**
18
+ - Modify: `package.json`
19
+
20
+ **Step 1: Install adm-zip and dotenv**
21
+
22
+ ```bash
23
+ npm install adm-zip dotenv --prefix /Volumes/storage/01_Projects/whoop/hae-vault
24
+ ```
25
+
26
+ Expected: both appear in `dependencies` in package.json. `adm-zip` and `dotenv` both ship their own TypeScript types — no `@types/*` packages needed.
27
+
28
+ **Step 2: Verify tests still pass**
29
+
30
+ ```bash
31
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault
32
+ ```
33
+
34
+ Expected: `pass 44`, `fail 0`
35
+
36
+ **Step 3: Commit**
37
+
38
+ ```bash
39
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && git add package.json package-lock.json && git commit -m "chore: add adm-zip and dotenv dependencies"
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Task 2: Add `import_log` table to schema
45
+
46
+ **Files:**
47
+ - Modify: `src/db/schema.ts`
48
+ - Modify: `tests/db-schema.test.ts`
49
+
50
+ **Step 1: Write the failing test**
51
+
52
+ Add to the bottom of `tests/db-schema.test.ts`:
53
+
54
+ ```typescript
55
+ test('creates import_log table', () => {
56
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='import_log'").get();
57
+ assert.ok(row);
58
+ });
59
+ ```
60
+
61
+ **Step 2: Run to verify it fails**
62
+
63
+ ```bash
64
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault 2>&1 | grep -A3 "import_log"
65
+ ```
66
+
67
+ Expected: `fail 1` — `import_log` table doesn't exist yet.
68
+
69
+ **Step 3: Add the table to schema.ts**
70
+
71
+ In `src/db/schema.ts`, inside the `db.exec(...)` template string, after the `sync_log` CREATE block, add:
72
+
73
+ ```typescript
74
+ CREATE TABLE IF NOT EXISTS import_log (
75
+ id INTEGER PRIMARY KEY,
76
+ filename TEXT NOT NULL,
77
+ file_hash TEXT NOT NULL UNIQUE,
78
+ imported_at TEXT NOT NULL,
79
+ metrics_added INTEGER,
80
+ sleep_added INTEGER,
81
+ workouts_added INTEGER
82
+ );
83
+ ```
84
+
85
+ **Step 4: Run tests to verify passing**
86
+
87
+ ```bash
88
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault
89
+ ```
90
+
91
+ Expected: `pass 45`, `fail 0`
92
+
93
+ **Step 5: Commit**
94
+
95
+ ```bash
96
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && git add src/db/schema.ts tests/db-schema.test.ts && git commit -m "feat: add import_log table for file deduplication"
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Task 3: Change `ingest()` to return `IngestResult`
102
+
103
+ **Files:**
104
+ - Modify: `src/server/ingest.ts`
105
+ - Modify: `tests/ingest.test.ts`
106
+
107
+ **Step 1: Write the failing test**
108
+
109
+ Add to the bottom of `tests/ingest.test.ts`:
110
+
111
+ ```typescript
112
+ test('ingest returns IngestResult with counts', () => {
113
+ const freshPayload: HaePayload = {
114
+ data: {
115
+ metrics: [
116
+ { name: 'step_count', units: 'count', data: [{ date: '2026-02-01 10:00:00 +0000', qty: 1000 }] },
117
+ {
118
+ name: 'sleep_analysis', units: 'hr',
119
+ data: [{
120
+ sleepStart: '2026-02-01 22:00:00 +0000',
121
+ sleepEnd: '2026-02-02 06:00:00 +0000',
122
+ core: 2.0, deep: 1.0, rem: 1.0, awake: 0.2, asleep: 4.0, inBed: 4.5,
123
+ source: 'Apple Watch'
124
+ }]
125
+ }
126
+ ],
127
+ workouts: [
128
+ { name: 'Cycling', start: '2026-02-01 07:00:00 +0000', end: '2026-02-01 08:00:00 +0000' }
129
+ ]
130
+ }
131
+ };
132
+ const result = ingest(db, freshPayload, { target: 'test', sessionId: 'sess-count', automationName: 'test', automationPeriod: 'manual' });
133
+ assert.ok(typeof result.metricsAdded === 'number');
134
+ assert.ok(typeof result.sleepAdded === 'number');
135
+ assert.ok(typeof result.workoutsAdded === 'number');
136
+ assert.equal(result.metricsAdded, 1); // step_count only (sleep goes to sleep table)
137
+ assert.equal(result.sleepAdded, 1);
138
+ assert.equal(result.workoutsAdded, 1);
139
+ });
140
+ ```
141
+
142
+ **Step 2: Run to verify it fails**
143
+
144
+ ```bash
145
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault 2>&1 | grep -E "(fail|IngestResult)"
146
+ ```
147
+
148
+ Expected: `fail 1` — TypeScript error or test failure on result type.
149
+
150
+ **Step 3: Update `src/server/ingest.ts`**
151
+
152
+ Replace the entire file with:
153
+
154
+ ```typescript
155
+ import type Database from 'better-sqlite3';
156
+ import type { HaePayload, SleepDatapoint } from '../types/hae.js';
157
+ import { parseMetric } from '../parse/metrics.js';
158
+ import { normalizeSleep } from '../parse/sleep.js';
159
+ import { parseWorkout } from '../parse/workouts.js';
160
+ import { upsertMetrics } from '../db/metrics.js';
161
+ import { upsertSleep } from '../db/sleep.js';
162
+ import { upsertWorkout } from '../db/workouts.js';
163
+
164
+ export interface IngestOptions {
165
+ target: string;
166
+ sessionId: string | null;
167
+ automationName?: string;
168
+ automationPeriod?: string;
169
+ }
170
+
171
+ export interface IngestResult {
172
+ metricsAdded: number;
173
+ sleepAdded: number;
174
+ workoutsAdded: number;
175
+ }
176
+
177
+ export function ingest(db: Database.Database, payload: HaePayload, opts: IngestOptions): IngestResult {
178
+ const { target, sessionId } = opts;
179
+ const { data } = payload;
180
+
181
+ let metricsAdded = 0;
182
+ let sleepAdded = 0;
183
+ let workoutsAdded = 0;
184
+
185
+ // Process metrics
186
+ for (const m of data.metrics ?? []) {
187
+ if (m.name === 'sleep_analysis') {
188
+ for (const dp of (m.data as SleepDatapoint[])) {
189
+ const row = normalizeSleep(dp, target, sessionId);
190
+ upsertSleep(db, row);
191
+ sleepAdded++;
192
+ }
193
+ } else {
194
+ const rows = parseMetric(m, target, sessionId);
195
+ upsertMetrics(db, rows);
196
+ metricsAdded += rows.length;
197
+ }
198
+ }
199
+
200
+ // Process workouts
201
+ for (const w of data.workouts ?? []) {
202
+ const row = parseWorkout(w, target, sessionId);
203
+ upsertWorkout(db, row);
204
+ workoutsAdded++;
205
+ }
206
+
207
+ // Log sync
208
+ db.prepare(`
209
+ INSERT INTO sync_log (received_at, target, session_id, metrics_count, workouts_count, automation_name, automation_period)
210
+ VALUES (?, ?, ?, ?, ?, ?, ?)
211
+ `).run(
212
+ new Date().toISOString(),
213
+ target,
214
+ sessionId,
215
+ metricsAdded,
216
+ workoutsAdded,
217
+ opts.automationName ?? null,
218
+ opts.automationPeriod ?? null,
219
+ );
220
+
221
+ return { metricsAdded, sleepAdded, workoutsAdded };
222
+ }
223
+ ```
224
+
225
+ **Step 4: Run tests**
226
+
227
+ ```bash
228
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault
229
+ ```
230
+
231
+ Expected: `pass 46`, `fail 0`
232
+
233
+ **Step 5: Commit**
234
+
235
+ ```bash
236
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && git add src/server/ingest.ts tests/ingest.test.ts && git commit -m "feat: ingest() returns IngestResult with row counts"
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Task 4: Create `src/db/importLog.ts` + tests
242
+
243
+ **Files:**
244
+ - Create: `src/db/importLog.ts`
245
+ - Create: `tests/db-import-log.test.ts`
246
+
247
+ **Step 1: Write the failing tests**
248
+
249
+ Create `tests/db-import-log.test.ts`:
250
+
251
+ ```typescript
252
+ import { test, before, after } from 'node:test';
253
+ import assert from 'node:assert/strict';
254
+ import { mkdirSync, rmSync } from 'node:fs';
255
+ import { tmpdir } from 'node:os';
256
+ import { join } from 'node:path';
257
+ import { openDb, closeDb } from '../src/db/schema.js';
258
+ import { hasBeenImported, logImport } from '../src/db/importLog.js';
259
+ import type Database from 'better-sqlite3';
260
+
261
+ const testDir = join(tmpdir(), 'hae-vault-test-importlog-' + Date.now());
262
+ let db: Database.Database;
263
+ before(() => { mkdirSync(testDir, { recursive: true }); db = openDb(join(testDir, 'test.db')); });
264
+ after(() => { closeDb(db); rmSync(testDir, { recursive: true }); });
265
+
266
+ test('hasBeenImported returns false for unknown hash', () => {
267
+ assert.equal(hasBeenImported(db, 'deadbeef1234'), false);
268
+ });
269
+
270
+ test('logImport records the import', () => {
271
+ logImport(db, 'export.zip', 'abc123hash', { metricsAdded: 100, sleepAdded: 7, workoutsAdded: 3 });
272
+ const row = db.prepare('SELECT * FROM import_log WHERE file_hash = ?').get('abc123hash') as {
273
+ filename: string; metrics_added: number; sleep_added: number; workouts_added: number;
274
+ } | undefined;
275
+ assert.ok(row);
276
+ assert.equal(row!.filename, 'export.zip');
277
+ assert.equal(row!.metrics_added, 100);
278
+ assert.equal(row!.sleep_added, 7);
279
+ assert.equal(row!.workouts_added, 3);
280
+ });
281
+
282
+ test('hasBeenImported returns true after logImport', () => {
283
+ assert.equal(hasBeenImported(db, 'abc123hash'), true);
284
+ });
285
+
286
+ test('logImport with duplicate hash is a no-op (INSERT OR IGNORE)', () => {
287
+ // Should not throw
288
+ logImport(db, 'export-copy.zip', 'abc123hash', { metricsAdded: 999, sleepAdded: 999, workoutsAdded: 999 });
289
+ const count = db.prepare('SELECT COUNT(*) as c FROM import_log WHERE file_hash = ?').get('abc123hash') as { c: number };
290
+ assert.equal(count.c, 1); // still only one row
291
+ });
292
+ ```
293
+
294
+ **Step 2: Run to verify it fails**
295
+
296
+ ```bash
297
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault 2>&1 | grep -E "(fail|importLog)"
298
+ ```
299
+
300
+ Expected: `fail 4` — module not found.
301
+
302
+ **Step 3: Create `src/db/importLog.ts`**
303
+
304
+ ```typescript
305
+ import type Database from 'better-sqlite3';
306
+ import type { IngestResult } from '../server/ingest.js';
307
+
308
+ export function hasBeenImported(db: Database.Database, hash: string): boolean {
309
+ return db.prepare('SELECT id FROM import_log WHERE file_hash = ?').get(hash) !== undefined;
310
+ }
311
+
312
+ export function logImport(
313
+ db: Database.Database,
314
+ filename: string,
315
+ hash: string,
316
+ result: IngestResult,
317
+ ): void {
318
+ db.prepare(`
319
+ INSERT OR IGNORE INTO import_log (filename, file_hash, imported_at, metrics_added, sleep_added, workouts_added)
320
+ VALUES (?, ?, ?, ?, ?, ?)
321
+ `).run(filename, hash, new Date().toISOString(), result.metricsAdded, result.sleepAdded, result.workoutsAdded);
322
+ }
323
+ ```
324
+
325
+ **Step 4: Run tests**
326
+
327
+ ```bash
328
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault
329
+ ```
330
+
331
+ Expected: `pass 50`, `fail 0`
332
+
333
+ **Step 5: Commit**
334
+
335
+ ```bash
336
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && git add src/db/importLog.ts tests/db-import-log.test.ts && git commit -m "feat: add importLog DB helpers with dedup check"
337
+ ```
338
+
339
+ ---
340
+
341
+ ## Task 5: Create `src/util/zip.ts` + tests
342
+
343
+ **Files:**
344
+ - Create: `src/util/zip.ts`
345
+ - Create: `tests/util-zip.test.ts`
346
+
347
+ **Step 1: Write the failing tests**
348
+
349
+ Create `tests/util-zip.test.ts`:
350
+
351
+ ```typescript
352
+ import { test } from 'node:test';
353
+ import assert from 'node:assert/strict';
354
+ import AdmZip from 'adm-zip';
355
+ import { extractPayloadFromZip } from '../src/util/zip.js';
356
+
357
+ function makeZip(filename: string, content: string): Buffer {
358
+ const zip = new AdmZip();
359
+ zip.addFile(filename, Buffer.from(content, 'utf-8'));
360
+ return zip.toBuffer();
361
+ }
362
+
363
+ const validPayload = JSON.stringify({ data: { metrics: [], workouts: [] } });
364
+
365
+ test('extracts payload from zip containing HealthAutoExport-*.json', () => {
366
+ const buf = makeZip('HealthAutoExport-test-2026-01-15.json', validPayload);
367
+ const result = extractPayloadFromZip(buf);
368
+ assert.ok(result);
369
+ assert.deepEqual(result!.data.metrics, []);
370
+ });
371
+
372
+ test('returns null when zip has no HealthAutoExport-*.json', () => {
373
+ const buf = makeZip('workout.gpx', '<gpx/>');
374
+ const result = extractPayloadFromZip(buf);
375
+ assert.equal(result, null);
376
+ });
377
+
378
+ test('returns null when matching entry contains invalid JSON', () => {
379
+ const buf = makeZip('HealthAutoExport-bad.json', 'not json {{{');
380
+ const result = extractPayloadFromZip(buf);
381
+ assert.equal(result, null);
382
+ });
383
+
384
+ test('returns null when JSON lacks data field', () => {
385
+ const buf = makeZip('HealthAutoExport-nodatafield.json', JSON.stringify({ other: true }));
386
+ const result = extractPayloadFromZip(buf);
387
+ assert.equal(result, null);
388
+ });
389
+
390
+ test('ignores .gpx entries and finds the .json', () => {
391
+ const zip = new AdmZip();
392
+ zip.addFile('route.gpx', Buffer.from('<gpx/>', 'utf-8'));
393
+ zip.addFile('HealthAutoExport-2026.json', Buffer.from(validPayload, 'utf-8'));
394
+ const buf = zip.toBuffer();
395
+ const result = extractPayloadFromZip(buf);
396
+ assert.ok(result);
397
+ });
398
+ ```
399
+
400
+ **Step 2: Run to verify it fails**
401
+
402
+ ```bash
403
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault 2>&1 | grep -E "(fail|zip)"
404
+ ```
405
+
406
+ Expected: `fail 5` — module not found.
407
+
408
+ **Step 3: Create `src/util/zip.ts`**
409
+
410
+ ```typescript
411
+ import AdmZip from 'adm-zip';
412
+ import type { HaePayload } from '../types/hae.js';
413
+
414
+ const HAE_JSON_PATTERN = /HealthAutoExport.*\.json$/i;
415
+
416
+ export function extractPayloadFromZip(buf: Buffer): HaePayload | null {
417
+ try {
418
+ const zip = new AdmZip(buf);
419
+ const entry = zip.getEntries().find(e => HAE_JSON_PATTERN.test(e.entryName));
420
+ if (!entry) return null;
421
+
422
+ let payload: unknown;
423
+ try {
424
+ payload = JSON.parse(entry.getData().toString('utf-8'));
425
+ } catch {
426
+ return null;
427
+ }
428
+
429
+ if (!payload || typeof payload !== 'object' || !('data' in payload)) return null;
430
+ return payload as HaePayload;
431
+ } catch {
432
+ return null;
433
+ }
434
+ }
435
+ ```
436
+
437
+ **Step 4: Run tests**
438
+
439
+ ```bash
440
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault
441
+ ```
442
+
443
+ Expected: `pass 55`, `fail 0`
444
+
445
+ **Step 5: Commit**
446
+
447
+ ```bash
448
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && git add src/util/zip.ts tests/util-zip.test.ts && git commit -m "feat: add ZIP extraction utility for HAE exports"
449
+ ```
450
+
451
+ ---
452
+
453
+ ## Task 6: Create `src/config.ts`
454
+
455
+ No tests for config itself (it reads `process.env` at import time — tested implicitly via integration). Implementation only.
456
+
457
+ **Files:**
458
+ - Create: `src/config.ts`
459
+ - Modify: `src/index.ts`
460
+
461
+ **Step 1: Create `src/config.ts`**
462
+
463
+ ```typescript
464
+ import { config as dotenvLoad } from 'dotenv';
465
+ import { existsSync } from 'node:fs';
466
+ import { homedir } from 'node:os';
467
+ import { join } from 'node:path';
468
+
469
+ function expandTilde(p: string): string {
470
+ if (p === '~' || p.startsWith('~/')) {
471
+ return homedir() + p.slice(1);
472
+ }
473
+ return p;
474
+ }
475
+
476
+ // Load .env: HVAULT_ENV_FILE env var overrides; fallback to CWD .env
477
+ const envFile = process.env.HVAULT_ENV_FILE ?? join(process.cwd(), '.env');
478
+ if (existsSync(envFile)) {
479
+ dotenvLoad({ path: envFile });
480
+ }
481
+
482
+ const DEFAULT_DB_PATH = join(homedir(), '.hae-vault', 'health.db');
483
+
484
+ export const config = {
485
+ dbPath: expandTilde(process.env.HVAULT_DB_PATH ?? DEFAULT_DB_PATH),
486
+ port: Number(process.env.HVAULT_PORT ?? 4242),
487
+ token: process.env.HVAULT_TOKEN,
488
+ watchDir: process.env.HVAULT_WATCH_DIR ? expandTilde(process.env.HVAULT_WATCH_DIR) : undefined,
489
+ watchInterval: Number(process.env.HVAULT_WATCH_INTERVAL ?? 60),
490
+ target: process.env.HVAULT_TARGET ?? 'default',
491
+ } as const;
492
+ ```
493
+
494
+ **Step 2: Import config first in `src/index.ts`**
495
+
496
+ Replace the current `src/index.ts` with:
497
+
498
+ ```typescript
499
+ #!/usr/bin/env node
500
+ import './config.js'; // load dotenv before any command runs
501
+ import { program } from './cli/index.js';
502
+ program.parse();
503
+ ```
504
+
505
+ **Step 3: Build to verify no TypeScript errors**
506
+
507
+ ```bash
508
+ npm run build --prefix /Volumes/storage/01_Projects/whoop/hae-vault
509
+ ```
510
+
511
+ Expected: exits 0, no errors.
512
+
513
+ **Step 4: Run tests**
514
+
515
+ ```bash
516
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault
517
+ ```
518
+
519
+ Expected: same pass count, `fail 0`
520
+
521
+ **Step 5: Commit**
522
+
523
+ ```bash
524
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && git add src/config.ts src/index.ts && git commit -m "feat: add config singleton with dotenv + env var support"
525
+ ```
526
+
527
+ ---
528
+
529
+ ## Task 7: Wire config into `serve.ts` and update `import.ts`
530
+
531
+ **Files:**
532
+ - Modify: `src/cli/serve.ts`
533
+ - Modify: `src/cli/import.ts`
534
+
535
+ **Step 1: Update `src/cli/serve.ts`**
536
+
537
+ Replace the entire file:
538
+
539
+ ```typescript
540
+ import { Command } from 'commander';
541
+ import { createApp } from '../server/app.js';
542
+ import { openDb } from '../db/schema.js';
543
+ import { config } from '../config.js';
544
+
545
+ export const serveCommand = new Command('serve')
546
+ .description('Start HTTP server to receive Health Auto Export pushes')
547
+ .option('-p, --port <number>', 'Port to listen on', String(config.port))
548
+ .option('--token <secret>', 'Require Authorization: Bearer <secret>', config.token)
549
+ .action((opts) => {
550
+ const db = openDb(config.dbPath);
551
+ const app = createApp(db, { token: opts.token });
552
+ const port = parseInt(opts.port, 10);
553
+ app.listen(port, () => {
554
+ console.log(`hvault server listening on http://0.0.0.0:${port}/api/ingest`);
555
+ if (opts.token) console.log('Auth: Bearer token required');
556
+ });
557
+ });
558
+ ```
559
+
560
+ **Step 2: Replace `src/cli/import.ts`**
561
+
562
+ Replace the entire file:
563
+
564
+ ```typescript
565
+ import { Command } from 'commander';
566
+ import { readFileSync } from 'node:fs';
567
+ import { createHash } from 'node:crypto';
568
+ import { openDb } from '../db/schema.js';
569
+ import { ingest } from '../server/ingest.js';
570
+ import { hasBeenImported, logImport } from '../db/importLog.js';
571
+ import { extractPayloadFromZip } from '../util/zip.js';
572
+ import { config } from '../config.js';
573
+ import type { HaePayload } from '../types/hae.js';
574
+
575
+ function sha256(buf: Buffer): string {
576
+ return createHash('sha256').update(buf).digest('hex');
577
+ }
578
+
579
+ function loadFile(file: string): { payload: HaePayload; hash: string } {
580
+ let buf: Buffer;
581
+ try {
582
+ buf = readFileSync(file);
583
+ } catch (err) {
584
+ console.error(JSON.stringify({ error: `Cannot read file: ${String(err)}` }));
585
+ process.exit(1);
586
+ }
587
+
588
+ const hash = sha256(buf);
589
+
590
+ let payload: HaePayload | null;
591
+ if (file.toLowerCase().endsWith('.zip')) {
592
+ payload = extractPayloadFromZip(buf);
593
+ if (!payload) {
594
+ console.error(JSON.stringify({ error: 'No valid HealthAutoExport-*.json found in zip' }));
595
+ process.exit(1);
596
+ }
597
+ } else {
598
+ try {
599
+ payload = JSON.parse(buf.toString('utf-8')) as HaePayload;
600
+ } catch (err) {
601
+ console.error(JSON.stringify({ error: `Invalid JSON: ${String(err)}` }));
602
+ process.exit(1);
603
+ }
604
+ if (!payload?.data) {
605
+ console.error(JSON.stringify({ error: 'Missing data field — not a valid HAE export' }));
606
+ process.exit(1);
607
+ }
608
+ }
609
+
610
+ return { payload, hash };
611
+ }
612
+
613
+ export const importCommand = new Command('import')
614
+ .description('Import a Health Auto Export JSON or ZIP file into the database')
615
+ .argument('<file>', 'Path to the HAE JSON or ZIP export file')
616
+ .option('--target <name>', 'Target name (device/person identifier)', config.target)
617
+ .option('--pretty', 'Pretty-print summary JSON', false)
618
+ .action((file: string, opts) => {
619
+ const db = openDb(config.dbPath);
620
+ const { payload, hash } = loadFile(file);
621
+
622
+ if (hasBeenImported(db, hash)) {
623
+ const result = { skipped: true, reason: 'already imported', file, hash };
624
+ console.log(opts.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
625
+ return;
626
+ }
627
+
628
+ const ingestResult = ingest(db, payload, {
629
+ target: opts.target,
630
+ sessionId: null,
631
+ automationName: 'file-import',
632
+ automationPeriod: 'manual',
633
+ });
634
+
635
+ logImport(db, file, hash, ingestResult);
636
+
637
+ const result = {
638
+ ok: true,
639
+ file,
640
+ target: opts.target,
641
+ hash,
642
+ added: {
643
+ metrics: ingestResult.metricsAdded,
644
+ sleep: ingestResult.sleepAdded,
645
+ workouts: ingestResult.workoutsAdded,
646
+ },
647
+ };
648
+ console.log(opts.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
649
+ });
650
+ ```
651
+
652
+ **Step 3: Build and test**
653
+
654
+ ```bash
655
+ npm run build --prefix /Volumes/storage/01_Projects/whoop/hae-vault && npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault
656
+ ```
657
+
658
+ Expected: build succeeds, all tests pass, `fail 0`
659
+
660
+ **Step 4: Commit**
661
+
662
+ ```bash
663
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && git add src/cli/serve.ts src/cli/import.ts && git commit -m "feat: wire config into serve/import, add ZIP + dedup to import"
664
+ ```
665
+
666
+ ---
667
+
668
+ ## Task 8: Create `hvault watch` command
669
+
670
+ **Files:**
671
+ - Create: `src/cli/watch.ts`
672
+ - Modify: `src/cli/index.ts`
673
+ - Create: `tests/cli-watch.test.ts`
674
+
675
+ **Step 1: Write the failing test**
676
+
677
+ Create `tests/cli-watch.test.ts`:
678
+
679
+ ```typescript
680
+ import { test, before, after } from 'node:test';
681
+ import assert from 'node:assert/strict';
682
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
683
+ import { tmpdir } from 'node:os';
684
+ import { join } from 'node:path';
685
+ import { openDb, closeDb } from '../src/db/schema.js';
686
+ import { tick } from '../src/cli/watch.js';
687
+ import { hasBeenImported } from '../src/db/importLog.js';
688
+ import type Database from 'better-sqlite3';
689
+
690
+ const testDir = join(tmpdir(), 'hae-vault-test-watch-' + Date.now());
691
+ const watchDir = join(testDir, 'watch');
692
+ let db: Database.Database;
693
+
694
+ const haeJson = JSON.stringify({
695
+ data: {
696
+ metrics: [{ name: 'step_count', units: 'count', data: [{ date: '2026-01-10 10:00:00 +0000', qty: 5000 }] }],
697
+ workouts: []
698
+ }
699
+ });
700
+
701
+ before(() => {
702
+ mkdirSync(testDir, { recursive: true });
703
+ mkdirSync(watchDir, { recursive: true });
704
+ db = openDb(join(testDir, 'test.db'));
705
+ });
706
+
707
+ after(() => {
708
+ closeDb(db);
709
+ rmSync(testDir, { recursive: true });
710
+ });
711
+
712
+ test('tick imports a new HealthAutoExport-*.json file', () => {
713
+ writeFileSync(join(watchDir, 'HealthAutoExport-test.json'), haeJson);
714
+ const result = tick(db, watchDir, 'default');
715
+ assert.equal(result.found, 1);
716
+ assert.equal(result.imported, 1);
717
+ assert.equal(result.skipped, 0);
718
+ });
719
+
720
+ test('tick skips already-imported file (dedup)', () => {
721
+ // Same file still in dir — hash already in import_log
722
+ const result = tick(db, watchDir, 'default');
723
+ assert.equal(result.found, 1);
724
+ assert.equal(result.imported, 0);
725
+ assert.equal(result.skipped, 1);
726
+ });
727
+
728
+ test('tick ignores non-HAE files', () => {
729
+ writeFileSync(join(watchDir, 'random.txt'), 'not a health export');
730
+ writeFileSync(join(watchDir, 'workout.gpx'), '<gpx/>');
731
+ const result = tick(db, watchDir, 'default');
732
+ assert.equal(result.found, 1); // only the HAE json counts as "found"
733
+ assert.equal(result.imported, 0);
734
+ });
735
+
736
+ test('tick records import in import_log', () => {
737
+ writeFileSync(join(watchDir, 'HealthAutoExport-new.json'), JSON.stringify({
738
+ data: { metrics: [{ name: 'step_count', units: 'count', data: [{ date: '2026-01-11 10:00:00 +0000', qty: 6000 }] }], workouts: [] }
739
+ }));
740
+ tick(db, watchDir, 'default');
741
+ const newFilePath = join(watchDir, 'HealthAutoExport-new.json');
742
+ // After tick, the file should be marked as imported (dedup check via a second tick)
743
+ const result2 = tick(db, watchDir, 'default');
744
+ assert.equal(result2.imported, 0); // all skipped on second pass
745
+ });
746
+ ```
747
+
748
+ **Step 2: Run to verify it fails**
749
+
750
+ ```bash
751
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault 2>&1 | grep -E "(fail|watch)"
752
+ ```
753
+
754
+ Expected: `fail 4` — module not found.
755
+
756
+ **Step 3: Create `src/cli/watch.ts`**
757
+
758
+ ```typescript
759
+ import { Command } from 'commander';
760
+ import { readdirSync, readFileSync } from 'node:fs';
761
+ import { createHash } from 'node:crypto';
762
+ import { join } from 'node:path';
763
+ import type Database from 'better-sqlite3';
764
+ import { openDb } from '../db/schema.js';
765
+ import { ingest } from '../server/ingest.js';
766
+ import { hasBeenImported, logImport } from '../db/importLog.js';
767
+ import { extractPayloadFromZip } from '../util/zip.js';
768
+ import { config } from '../config.js';
769
+ import type { HaePayload } from '../types/hae.js';
770
+
771
+ const HAE_PATTERN = /^HealthAutoExport.*\.(zip|json)$/i;
772
+
773
+ function sha256(buf: Buffer): string {
774
+ return createHash('sha256').update(buf).digest('hex');
775
+ }
776
+
777
+ function loadBuf(buf: Buffer, filename: string): HaePayload | null {
778
+ if (filename.toLowerCase().endsWith('.zip')) {
779
+ return extractPayloadFromZip(buf);
780
+ }
781
+ try {
782
+ const p = JSON.parse(buf.toString('utf-8')) as HaePayload;
783
+ return p?.data ? p : null;
784
+ } catch {
785
+ return null;
786
+ }
787
+ }
788
+
789
+ export interface TickResult {
790
+ tick: string;
791
+ dir: string;
792
+ found: number;
793
+ imported: number;
794
+ skipped: number;
795
+ }
796
+
797
+ export function tick(db: Database.Database, watchDir: string, target: string): TickResult {
798
+ const now = new Date().toISOString();
799
+ let found = 0, imported = 0, skipped = 0;
800
+
801
+ let files: string[];
802
+ try {
803
+ files = readdirSync(watchDir).filter(f => HAE_PATTERN.test(f));
804
+ } catch (err) {
805
+ console.error(JSON.stringify({ error: `Cannot read watch dir: ${String(err)}` }));
806
+ return { tick: now, dir: watchDir, found, imported, skipped };
807
+ }
808
+
809
+ found = files.length;
810
+
811
+ for (const filename of files) {
812
+ const filepath = join(watchDir, filename);
813
+ let buf: Buffer;
814
+ try {
815
+ buf = readFileSync(filepath);
816
+ } catch {
817
+ skipped++;
818
+ continue;
819
+ }
820
+
821
+ const hash = sha256(buf);
822
+ if (hasBeenImported(db, hash)) {
823
+ skipped++;
824
+ continue;
825
+ }
826
+
827
+ const payload = loadBuf(buf, filename);
828
+ if (!payload) {
829
+ skipped++;
830
+ continue;
831
+ }
832
+
833
+ const result = ingest(db, payload, {
834
+ target,
835
+ sessionId: null,
836
+ automationName: 'watch',
837
+ automationPeriod: 'manual',
838
+ });
839
+
840
+ logImport(db, filepath, hash, result);
841
+ console.log(JSON.stringify({ imported: filename, target, ...result }));
842
+ imported++;
843
+ }
844
+
845
+ const summary: TickResult = { tick: now, dir: watchDir, found, imported, skipped };
846
+ console.log(JSON.stringify(summary));
847
+ return summary;
848
+ }
849
+
850
+ export const watchCommand = new Command('watch')
851
+ .description('Poll a directory for new HAE exports and auto-import them')
852
+ .option('--dir <path>', 'Directory to watch', config.watchDir)
853
+ .option('--interval <seconds>', 'Poll interval in seconds', String(config.watchInterval))
854
+ .option('--target <name>', 'Target name', config.target)
855
+ .action((opts) => {
856
+ const watchDir: string | undefined = opts.dir;
857
+ if (!watchDir) {
858
+ console.error(JSON.stringify({ error: 'Watch directory required: use --dir or set HVAULT_WATCH_DIR' }));
859
+ process.exit(1);
860
+ }
861
+
862
+ const intervalMs = Number(opts.interval) * 1000;
863
+ const db = openDb(config.dbPath);
864
+
865
+ console.log(JSON.stringify({ watching: watchDir, intervalSeconds: Number(opts.interval), target: opts.target }));
866
+
867
+ tick(db, watchDir, opts.target);
868
+ setInterval(() => tick(db, watchDir, opts.target), intervalMs);
869
+ });
870
+ ```
871
+
872
+ **Step 4: Register watch in `src/cli/index.ts`**
873
+
874
+ Add import and addCommand. The file currently ends with `program.addCommand(statsCommand);`. Add after it:
875
+
876
+ ```typescript
877
+ import { watchCommand } from './watch.js';
878
+ // ...
879
+ program.addCommand(watchCommand);
880
+ ```
881
+
882
+ Full updated `src/cli/index.ts`:
883
+
884
+ ```typescript
885
+ import { Command } from 'commander';
886
+ import { serveCommand } from './serve.js';
887
+ import { metricsCommand } from './metrics.js';
888
+ import { sleepCommand } from './sleep.js';
889
+ import { workoutsCommand } from './workouts.js';
890
+ import { summaryCommand } from './summary.js';
891
+ import { queryCommand } from './query.js';
892
+ import { sourcesCommand, lastSyncCommand, statsCommand } from './info.js';
893
+ import { importCommand } from './import.js';
894
+ import { watchCommand } from './watch.js';
895
+
896
+ export const program = new Command();
897
+ program
898
+ .name('hvault')
899
+ .description('Apple Health data vault — ingest + query')
900
+ .version('0.1.0');
901
+
902
+ program.addCommand(serveCommand);
903
+ program.addCommand(importCommand);
904
+ program.addCommand(watchCommand);
905
+ program.addCommand(metricsCommand);
906
+ program.addCommand(sleepCommand);
907
+ program.addCommand(workoutsCommand);
908
+ program.addCommand(summaryCommand);
909
+ program.addCommand(queryCommand);
910
+ program.addCommand(sourcesCommand);
911
+ program.addCommand(lastSyncCommand);
912
+ program.addCommand(statsCommand);
913
+ ```
914
+
915
+ **Step 5: Build and run all tests**
916
+
917
+ ```bash
918
+ npm run build --prefix /Volumes/storage/01_Projects/whoop/hae-vault && npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault
919
+ ```
920
+
921
+ Expected: build succeeds, `fail 0`. Pass count should be 55+ (all prior + 4 watch tests).
922
+
923
+ **Step 6: Commit**
924
+
925
+ ```bash
926
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && git add src/cli/watch.ts src/cli/index.ts tests/cli-watch.test.ts && git commit -m "feat: add hvault watch command with polling and dedup"
927
+ ```
928
+
929
+ ---
930
+
931
+ ## Task 9: Final verification
932
+
933
+ **Step 1: Full clean build**
934
+
935
+ ```bash
936
+ npm run build --prefix /Volumes/storage/01_Projects/whoop/hae-vault
937
+ ```
938
+
939
+ Expected: exits 0, no TypeScript errors.
940
+
941
+ **Step 2: Full test suite**
942
+
943
+ ```bash
944
+ npm test --prefix /Volumes/storage/01_Projects/whoop/hae-vault
945
+ ```
946
+
947
+ Expected: `fail 0`. All tests pass.
948
+
949
+ **Step 3: Smoke test the CLI**
950
+
951
+ ```bash
952
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && node dist/index.js --help
953
+ node dist/index.js import --help
954
+ node dist/index.js watch --help
955
+ node dist/index.js serve --help
956
+ ```
957
+
958
+ Verify: `import` help mentions ZIP, `watch` appears in command list, `serve` shows default port.
959
+
960
+ **Step 4: Final commit if any cleanup needed, then tag**
961
+
962
+ ```bash
963
+ cd /Volumes/storage/01_Projects/whoop/hae-vault && git log --oneline -10
964
+ ```
965
+
966
+ Confirm all feature commits are present.