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.
- package/.env.example +7 -0
- package/CLAUDE.md +220 -0
- package/README.md +206 -0
- package/SKILL.md +60 -0
- package/dist/cli/dashboard.d.ts +3 -0
- package/dist/cli/dashboard.d.ts.map +1 -0
- package/dist/cli/dashboard.js +206 -0
- package/dist/cli/dashboard.js.map +1 -0
- package/dist/cli/import.d.ts +3 -0
- package/dist/cli/import.d.ts.map +1 -0
- package/dist/cli/import.js +78 -0
- package/dist/cli/import.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +31 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/info.d.ts +5 -0
- package/dist/cli/info.d.ts.map +1 -0
- package/dist/cli/info.js +34 -0
- package/dist/cli/info.js.map +1 -0
- package/dist/cli/metrics.d.ts +3 -0
- package/dist/cli/metrics.d.ts.map +1 -0
- package/dist/cli/metrics.js +20 -0
- package/dist/cli/metrics.js.map +1 -0
- package/dist/cli/query.d.ts +3 -0
- package/dist/cli/query.d.ts.map +1 -0
- package/dist/cli/query.js +18 -0
- package/dist/cli/query.js.map +1 -0
- package/dist/cli/serve.d.ts +3 -0
- package/dist/cli/serve.d.ts.map +1 -0
- package/dist/cli/serve.js +19 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/cli/sleep.d.ts +3 -0
- package/dist/cli/sleep.d.ts.map +1 -0
- package/dist/cli/sleep.js +19 -0
- package/dist/cli/sleep.js.map +1 -0
- package/dist/cli/summary.d.ts +3 -0
- package/dist/cli/summary.d.ts.map +1 -0
- package/dist/cli/summary.js +53 -0
- package/dist/cli/summary.js.map +1 -0
- package/dist/cli/trends.d.ts +3 -0
- package/dist/cli/trends.d.ts.map +1 -0
- package/dist/cli/trends.js +77 -0
- package/dist/cli/trends.js.map +1 -0
- package/dist/cli/watch.d.ts +12 -0
- package/dist/cli/watch.d.ts.map +1 -0
- package/dist/cli/watch.js +89 -0
- package/dist/cli/watch.js.map +1 -0
- package/dist/cli/workouts.d.ts +3 -0
- package/dist/cli/workouts.d.ts.map +1 -0
- package/dist/cli/workouts.js +19 -0
- package/dist/cli/workouts.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +25 -0
- package/dist/config.js.map +1 -0
- package/dist/db/importLog.d.ts +5 -0
- package/dist/db/importLog.d.ts.map +1 -0
- package/dist/db/importLog.js +10 -0
- package/dist/db/importLog.js.map +1 -0
- package/dist/db/metrics.d.ts +4 -0
- package/dist/db/metrics.d.ts.map +1 -0
- package/dist/db/metrics.js +14 -0
- package/dist/db/metrics.js.map +1 -0
- package/dist/db/schema.d.ts +5 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +100 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/sleep.d.ts +4 -0
- package/dist/db/sleep.d.ts.map +1 -0
- package/dist/db/sleep.js +13 -0
- package/dist/db/sleep.js.map +1 -0
- package/dist/db/workouts.d.ts +4 -0
- package/dist/db/workouts.d.ts.map +1 -0
- package/dist/db/workouts.js +11 -0
- package/dist/db/workouts.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/parse/metrics.d.ts +17 -0
- package/dist/parse/metrics.d.ts.map +1 -0
- package/dist/parse/metrics.js +33 -0
- package/dist/parse/metrics.js.map +1 -0
- package/dist/parse/sleep.d.ts +23 -0
- package/dist/parse/sleep.d.ts.map +1 -0
- package/dist/parse/sleep.js +58 -0
- package/dist/parse/sleep.js.map +1 -0
- package/dist/parse/time.d.ts +4 -0
- package/dist/parse/time.d.ts.map +1 -0
- package/dist/parse/time.js +41 -0
- package/dist/parse/time.js.map +1 -0
- package/dist/parse/workouts.d.ts +17 -0
- package/dist/parse/workouts.d.ts.map +1 -0
- package/dist/parse/workouts.js +24 -0
- package/dist/parse/workouts.js.map +1 -0
- package/dist/server/app.d.ts +5 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +39 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/ingest.d.ts +15 -0
- package/dist/server/ingest.d.ts.map +1 -0
- package/dist/server/ingest.js +41 -0
- package/dist/server/ingest.js.map +1 -0
- package/dist/types/hae.d.ts +103 -0
- package/dist/types/hae.d.ts.map +1 -0
- package/dist/types/hae.js +2 -0
- package/dist/types/hae.js.map +1 -0
- package/dist/util/zip.d.ts +3 -0
- package/dist/util/zip.d.ts.map +1 -0
- package/dist/util/zip.js +24 -0
- package/dist/util/zip.js.map +1 -0
- package/docs/COMMANDS.md +315 -0
- package/docs/plans/2026-02-18-hae-vault-initial-implementation.md +2015 -0
- package/docs/plans/2026-02-18-readme-dashboard-design.md +213 -0
- package/docs/plans/2026-02-18-readme-dashboard-plan.md +1306 -0
- package/docs/plans/2026-02-18-zip-env-watch-design.md +213 -0
- package/docs/plans/2026-02-18-zip-env-watch.md +966 -0
- package/package.json +57 -0
- package/src/cli/dashboard.ts +242 -0
- package/src/cli/import.ts +85 -0
- package/src/cli/index.ts +32 -0
- package/src/cli/info.ts +36 -0
- package/src/cli/metrics.ts +20 -0
- package/src/cli/query.ts +17 -0
- package/src/cli/serve.ts +18 -0
- package/src/cli/sleep.ts +19 -0
- package/src/cli/summary.ts +58 -0
- package/src/cli/trends.ts +103 -0
- package/src/cli/watch.ts +111 -0
- package/src/cli/workouts.ts +19 -0
- package/src/config.ts +28 -0
- package/src/db/importLog.ts +18 -0
- package/src/db/metrics.ts +15 -0
- package/src/db/schema.ts +105 -0
- package/src/db/sleep.ts +15 -0
- package/src/db/workouts.ts +13 -0
- package/src/index.ts +4 -0
- package/src/parse/metrics.ts +50 -0
- package/src/parse/sleep.ts +82 -0
- package/src/parse/time.ts +43 -0
- package/src/parse/workouts.ts +42 -0
- package/src/server/app.ts +46 -0
- package/src/server/ingest.ts +68 -0
- package/src/types/hae.ts +94 -0
- package/src/util/zip.ts +24 -0
- package/tests/cli-watch.test.ts +64 -0
- package/tests/db-import-log.test.ts +40 -0
- package/tests/db-metrics.test.ts +44 -0
- package/tests/db-schema.test.ts +55 -0
- package/tests/db-sleep.test.ts +36 -0
- package/tests/db-workouts.test.ts +34 -0
- package/tests/ingest.test.ts +99 -0
- package/tests/parse-metrics.test.ts +55 -0
- package/tests/parse-sleep.test.ts +65 -0
- package/tests/parse-time.test.ts +48 -0
- package/tests/parse-workouts.test.ts +43 -0
- package/tests/types.test.ts +27 -0
- package/tests/util-zip.test.ts +46 -0
- 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.
|