health-sync 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +145 -14
- package/package.json +1 -1
- package/src/cli.js +39 -15
- package/src/config.js +56 -8
- package/src/db.js +228 -42
- package/src/util.js +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Filipe Almeida
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,30 +1,161 @@
|
|
|
1
|
-
# health-sync
|
|
1
|
+
# health-sync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`health-sync` is an open-source CLI that pulls health and fitness data from multiple providers and stores it in a local SQLite database.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- SQLite storage (`records`, `sync_state`, `oauth_tokens`, `sync_runs`)
|
|
7
|
-
- Built-in providers: Oura, Withings, Hevy, Strava, Eight Sleep
|
|
8
|
-
- Plugin loading from package metadata (`healthSyncProviders`) and `[plugins.<id>] module=...`
|
|
5
|
+
It is designed as a personal data cache: first sync backfills history, then future syncs fetch incremental updates.
|
|
9
6
|
|
|
10
|
-
##
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
- Keep your health data in one local database you control.
|
|
10
|
+
- Build your own dashboards, analysis scripts, or exports on top of raw provider data.
|
|
11
|
+
- Avoid building one-off sync scripts for each provider.
|
|
12
|
+
|
|
13
|
+
## Supported Providers
|
|
14
|
+
|
|
15
|
+
- Oura (Cloud API v2, OAuth2)
|
|
16
|
+
- Withings (Advanced Health Data API, OAuth2)
|
|
17
|
+
- Hevy (public API, API key, Pro account required)
|
|
18
|
+
- Strava (OAuth2 or static access token)
|
|
19
|
+
- Eight Sleep (unofficial API)
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Node.js 20+
|
|
24
|
+
- SQLite (bundled through `better-sqlite3`)
|
|
25
|
+
- Provider credentials (API key and/or OAuth client settings depending on provider)
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Install globally from npm:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install -g health-sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or run from this repository:
|
|
11
36
|
|
|
12
37
|
```bash
|
|
13
|
-
cd node
|
|
14
38
|
npm install
|
|
39
|
+
npm link
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
1. Initialize config and DB:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
health-sync init
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
2. Edit `health-sync.toml`:
|
|
51
|
+
- Set `[app].db` if you do not want `./health.sqlite`
|
|
52
|
+
|
|
53
|
+
3. Run provider auth one provider at a time:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
health-sync auth oura
|
|
57
|
+
health-sync auth withings
|
|
58
|
+
health-sync auth strava
|
|
59
|
+
health-sync auth eightsleep
|
|
15
60
|
```
|
|
16
61
|
|
|
17
|
-
|
|
62
|
+
4. Sync data:
|
|
18
63
|
|
|
19
64
|
```bash
|
|
20
|
-
|
|
21
|
-
npm start -- --config ../health-sync.toml status
|
|
65
|
+
health-sync sync
|
|
22
66
|
```
|
|
23
67
|
|
|
24
|
-
|
|
68
|
+
5. Inspect sync state and counts:
|
|
25
69
|
|
|
26
70
|
```bash
|
|
27
|
-
health-sync
|
|
71
|
+
health-sync status
|
|
28
72
|
```
|
|
29
73
|
|
|
30
|
-
|
|
74
|
+
## Basic Configuration
|
|
75
|
+
|
|
76
|
+
By default, `health-sync` reads `./health-sync.toml`.
|
|
77
|
+
|
|
78
|
+
Use a custom config file with:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
health-sync --config /path/to/health-sync.toml sync
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Minimal example:
|
|
85
|
+
|
|
86
|
+
```toml
|
|
87
|
+
[app]
|
|
88
|
+
db = "./health.sqlite"
|
|
89
|
+
|
|
90
|
+
[hevy]
|
|
91
|
+
enabled = true
|
|
92
|
+
api_key = "YOUR_HEVY_API_KEY"
|
|
93
|
+
|
|
94
|
+
[strava]
|
|
95
|
+
enabled = true
|
|
96
|
+
client_id = "YOUR_CLIENT_ID"
|
|
97
|
+
client_secret = "YOUR_CLIENT_SECRET"
|
|
98
|
+
redirect_uri = "http://127.0.0.1:8486/callback"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See `health-sync.example.toml` for all provider options.
|
|
102
|
+
|
|
103
|
+
## CLI Commands
|
|
104
|
+
|
|
105
|
+
- `health-sync init`: create a scaffolded config (from `health-sync.example.toml`) and create DB tables
|
|
106
|
+
- `health-sync init-db`: create DB tables only (legacy)
|
|
107
|
+
- `health-sync auth <provider>`: run auth flow for one provider/plugin
|
|
108
|
+
- `health-sync sync`: run sync for all enabled providers
|
|
109
|
+
- `health-sync sync --providers oura strava`: sync only selected providers
|
|
110
|
+
- `health-sync providers`: list discovered providers and whether they are enabled
|
|
111
|
+
- `health-sync status`: print watermarks, record counts, and recent runs
|
|
112
|
+
|
|
113
|
+
`auth` notes:
|
|
114
|
+
|
|
115
|
+
- Oura, Withings, and Strava: OAuth flow (CLI prints auth URL and waits for callback URL/code).
|
|
116
|
+
- Eight Sleep: username/password grant (or static token).
|
|
117
|
+
- Hevy: no `auth` command; configure `[hevy].api_key` directly.
|
|
118
|
+
|
|
119
|
+
`auth` also scaffolds the provider section in `health-sync.toml` (enables it and, for Eight Sleep, writes default client id/secret if missing).
|
|
120
|
+
|
|
121
|
+
Global flags:
|
|
122
|
+
|
|
123
|
+
- `--config`: config file path
|
|
124
|
+
- `--db`: override SQLite DB path
|
|
125
|
+
|
|
126
|
+
## Data Storage
|
|
127
|
+
|
|
128
|
+
The database keeps raw JSON payloads and sync metadata in generic tables:
|
|
129
|
+
|
|
130
|
+
- `records`: provider/resource records
|
|
131
|
+
- `sync_state`: per-resource watermarks/cursors
|
|
132
|
+
- `.health-sync.creds`: stored provider credentials and OAuth tokens
|
|
133
|
+
- `sync_runs`: run history and per-sync counters
|
|
134
|
+
|
|
135
|
+
This schema is intentionally generic so upstream API changes are less likely to require migrations.
|
|
136
|
+
|
|
137
|
+
## Optional Plugin System
|
|
138
|
+
|
|
139
|
+
You can add external providers as in-process plugins.
|
|
140
|
+
|
|
141
|
+
- Discover installed plugins with `health-sync providers`
|
|
142
|
+
- Configure plugin blocks under `[plugins.<id>]`
|
|
143
|
+
- Enable them with `[plugins.<id>].enabled = true`
|
|
144
|
+
|
|
145
|
+
## Notes
|
|
146
|
+
|
|
147
|
+
- Eight Sleep integration uses unofficial endpoints and may break if the upstream API changes.
|
|
148
|
+
- Some providers use overlap windows to ensure incremental sync correctness.
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
Run checks and tests:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npm run check
|
|
156
|
+
npm test
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT. See `LICENSE`.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { openDb } from './db.js';
|
|
8
8
|
import { PluginHelpers, providerEnabled } from './plugins/base.js';
|
|
9
9
|
import { loadProviders } from './plugins/loader.js';
|
|
10
|
+
import { setRequestJsonVerbose } from './util.js';
|
|
10
11
|
|
|
11
12
|
const DEFAULT_CONFIG_PATH = 'health-sync.toml';
|
|
12
13
|
|
|
@@ -84,6 +85,12 @@ function parseAuthArgs(args) {
|
|
|
84
85
|
throw new Error(`Unknown auth option: ${arg}`);
|
|
85
86
|
}
|
|
86
87
|
if (!out.provider) {
|
|
88
|
+
if (arg === '-h' || arg === '--help') {
|
|
89
|
+
throw new Error('auth requires PROVIDER argument (e.g. health-sync auth oura)');
|
|
90
|
+
}
|
|
91
|
+
if (arg.startsWith('-')) {
|
|
92
|
+
throw new Error(`Invalid provider id: ${arg}`);
|
|
93
|
+
}
|
|
87
94
|
out.provider = arg;
|
|
88
95
|
continue;
|
|
89
96
|
}
|
|
@@ -98,17 +105,24 @@ function parseAuthArgs(args) {
|
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
function parseSyncArgs(args) {
|
|
101
|
-
const
|
|
108
|
+
const out = {
|
|
109
|
+
providers: [],
|
|
110
|
+
verbose: false,
|
|
111
|
+
};
|
|
102
112
|
|
|
103
113
|
for (let i = 0; i < args.length; i += 1) {
|
|
104
114
|
const arg = args[i];
|
|
115
|
+
if (arg === '-v' || arg === '--verbose') {
|
|
116
|
+
out.verbose = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
105
119
|
if (arg === '--providers') {
|
|
106
120
|
let consumed = false;
|
|
107
121
|
while (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
108
122
|
consumed = true;
|
|
109
123
|
const raw = args[i + 1];
|
|
110
124
|
for (const piece of String(raw).split(',').map((v) => v.trim()).filter(Boolean)) {
|
|
111
|
-
providers.push(piece);
|
|
125
|
+
out.providers.push(piece);
|
|
112
126
|
}
|
|
113
127
|
i += 1;
|
|
114
128
|
}
|
|
@@ -120,16 +134,14 @@ function parseSyncArgs(args) {
|
|
|
120
134
|
if (arg.startsWith('--providers=')) {
|
|
121
135
|
const raw = arg.slice('--providers='.length);
|
|
122
136
|
for (const piece of raw.split(',').map((v) => v.trim()).filter(Boolean)) {
|
|
123
|
-
providers.push(piece);
|
|
137
|
+
out.providers.push(piece);
|
|
124
138
|
}
|
|
125
139
|
continue;
|
|
126
140
|
}
|
|
127
141
|
throw new Error(`Unknown sync option: ${arg}`);
|
|
128
142
|
}
|
|
129
143
|
|
|
130
|
-
return
|
|
131
|
-
providers,
|
|
132
|
-
};
|
|
144
|
+
return out;
|
|
133
145
|
}
|
|
134
146
|
|
|
135
147
|
function parseProvidersArgs(args) {
|
|
@@ -196,6 +208,10 @@ function resolveDbPath(overrideDbPath, loadedConfig) {
|
|
|
196
208
|
return './health.sqlite';
|
|
197
209
|
}
|
|
198
210
|
|
|
211
|
+
function resolveCredsPath(configPath) {
|
|
212
|
+
return path.join(path.dirname(path.resolve(configPath)), '.health-sync.creds');
|
|
213
|
+
}
|
|
214
|
+
|
|
199
215
|
function enableHint(providerId) {
|
|
200
216
|
const builtin = new Set(['oura', 'withings', 'hevy', 'strava', 'eightsleep']);
|
|
201
217
|
if (builtin.has(providerId)) {
|
|
@@ -214,7 +230,7 @@ function usage() {
|
|
|
214
230
|
' auth <provider> Run provider authentication flow',
|
|
215
231
|
' --listen-host <host> OAuth callback listen host (default 127.0.0.1)',
|
|
216
232
|
' --listen-port <port> OAuth callback listen port (default 0 -> config redirect port)',
|
|
217
|
-
' sync [--providers a,b,c]
|
|
233
|
+
' sync [--providers a,b,c] [-v|--verbose] Sync enabled providers',
|
|
218
234
|
' providers [--verbose] List discovered providers',
|
|
219
235
|
' status Show sync state, counts, and recent runs',
|
|
220
236
|
].join('\n');
|
|
@@ -236,11 +252,13 @@ async function loadContext(configPath) {
|
|
|
236
252
|
|
|
237
253
|
async function cmdInit(parsed) {
|
|
238
254
|
const configPath = path.resolve(parsed.configPath);
|
|
255
|
+
const explicitDbPath = parsed.dbPath ? String(parsed.dbPath) : null;
|
|
256
|
+
|
|
257
|
+
initConfigFile(configPath, explicitDbPath);
|
|
258
|
+
|
|
239
259
|
const loaded = loadConfig(configPath);
|
|
240
260
|
const dbPath = resolveDbPath(parsed.dbPath, loaded);
|
|
241
|
-
|
|
242
|
-
initConfigFile(configPath, dbPath);
|
|
243
|
-
const db = openDb(dbPath);
|
|
261
|
+
const db = openDb(dbPath, { credsPath: resolveCredsPath(configPath) });
|
|
244
262
|
db.close();
|
|
245
263
|
|
|
246
264
|
console.log(`Initialized config: ${configPath}`);
|
|
@@ -253,7 +271,7 @@ async function cmdInitDb(parsed) {
|
|
|
253
271
|
const loaded = loadConfig(configPath);
|
|
254
272
|
const dbPath = resolveDbPath(parsed.dbPath, loaded);
|
|
255
273
|
|
|
256
|
-
const db = openDb(dbPath);
|
|
274
|
+
const db = openDb(dbPath, { credsPath: resolveCredsPath(configPath) });
|
|
257
275
|
db.close();
|
|
258
276
|
console.log(`Initialized database: ${path.resolve(dbPath)}`);
|
|
259
277
|
return 0;
|
|
@@ -261,14 +279,17 @@ async function cmdInitDb(parsed) {
|
|
|
261
279
|
|
|
262
280
|
async function cmdAuth(parsed) {
|
|
263
281
|
const configPath = path.resolve(parsed.configPath);
|
|
282
|
+
const explicitDbPath = parsed.dbPath ? String(parsed.dbPath) : null;
|
|
283
|
+
|
|
284
|
+
initConfigFile(configPath, explicitDbPath);
|
|
285
|
+
|
|
264
286
|
const loaded = loadConfig(configPath);
|
|
265
287
|
const dbPath = resolveDbPath(parsed.dbPath, loaded);
|
|
266
|
-
initConfigFile(configPath, dbPath);
|
|
267
288
|
|
|
268
289
|
scaffoldProviderConfig(configPath, parsed.options.provider);
|
|
269
290
|
|
|
270
291
|
const context = await loadContext(configPath);
|
|
271
|
-
const db = openDb(dbPath);
|
|
292
|
+
const db = openDb(dbPath, { credsPath: resolveCredsPath(configPath) });
|
|
272
293
|
|
|
273
294
|
try {
|
|
274
295
|
const plugin = context.providers.get(parsed.options.provider);
|
|
@@ -304,7 +325,8 @@ async function cmdSync(parsed) {
|
|
|
304
325
|
const configPath = path.resolve(parsed.configPath);
|
|
305
326
|
const context = await loadContext(configPath);
|
|
306
327
|
const dbPath = resolveDbPath(parsed.dbPath, context.loadedConfig);
|
|
307
|
-
const db = openDb(dbPath);
|
|
328
|
+
const db = openDb(dbPath, { credsPath: resolveCredsPath(configPath) });
|
|
329
|
+
const previousVerboseLogging = setRequestJsonVerbose(parsed.options.verbose);
|
|
308
330
|
|
|
309
331
|
try {
|
|
310
332
|
const discoveredIds = Array.from(context.providers.keys()).sort();
|
|
@@ -353,6 +375,7 @@ async function cmdSync(parsed) {
|
|
|
353
375
|
let successes = 0;
|
|
354
376
|
const failures = [];
|
|
355
377
|
for (const providerId of toSync) {
|
|
378
|
+
console.log(`Syncing provider: ${providerId}`);
|
|
356
379
|
try {
|
|
357
380
|
await context.providers.get(providerId).sync(db, context.loadedConfig.data, context.helpers, {
|
|
358
381
|
configPath,
|
|
@@ -378,6 +401,7 @@ async function cmdSync(parsed) {
|
|
|
378
401
|
|
|
379
402
|
return 0;
|
|
380
403
|
} finally {
|
|
404
|
+
setRequestJsonVerbose(previousVerboseLogging);
|
|
381
405
|
db.close();
|
|
382
406
|
}
|
|
383
407
|
}
|
|
@@ -409,7 +433,7 @@ async function cmdStatus(parsed) {
|
|
|
409
433
|
const configPath = path.resolve(parsed.configPath);
|
|
410
434
|
const loadedConfig = loadConfig(configPath);
|
|
411
435
|
const dbPath = resolveDbPath(parsed.dbPath, loadedConfig);
|
|
412
|
-
const db = openDb(dbPath);
|
|
436
|
+
const db = openDb(dbPath, { credsPath: resolveCredsPath(configPath) });
|
|
413
437
|
|
|
414
438
|
try {
|
|
415
439
|
const syncState = db.listSyncState();
|
package/src/config.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import TOML from '@iarna/toml';
|
|
4
5
|
|
|
6
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const EXAMPLE_CONFIG_PATH = path.resolve(MODULE_DIR, '../health-sync.example.toml');
|
|
8
|
+
|
|
5
9
|
const BUILTIN_DEFAULTS = {
|
|
6
10
|
app: {
|
|
7
11
|
db: './health.sqlite',
|
|
@@ -268,10 +272,43 @@ function writeToml(filePath, rawDoc) {
|
|
|
268
272
|
fs.writeFileSync(filePath, rendered, 'utf8');
|
|
269
273
|
}
|
|
270
274
|
|
|
271
|
-
|
|
275
|
+
function readExampleConfigTemplate() {
|
|
276
|
+
if (fs.existsSync(EXAMPLE_CONFIG_PATH)) {
|
|
277
|
+
return fs.readFileSync(EXAMPLE_CONFIG_PATH, 'utf8');
|
|
278
|
+
}
|
|
279
|
+
return TOML.stringify(defaultConfig());
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function renderScaffoldConfig(dbPath = null) {
|
|
283
|
+
const template = readExampleConfigTemplate();
|
|
284
|
+
if (!dbPath) {
|
|
285
|
+
return template;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const dbLine = `db = ${JSON.stringify(String(dbPath))}`;
|
|
289
|
+
if (template.includes('# db = "./health.sqlite"')) {
|
|
290
|
+
return template.replace('# db = "./health.sqlite"', dbLine);
|
|
291
|
+
}
|
|
292
|
+
if (template.includes('[app]')) {
|
|
293
|
+
return template.replace('[app]', `[app]\n${dbLine}`);
|
|
294
|
+
}
|
|
295
|
+
return `[app]\n${dbLine}\n\n${template}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function initConfigFile(configPath, dbPath = null) {
|
|
272
299
|
const resolved = path.resolve(configPath);
|
|
273
|
-
|
|
274
|
-
|
|
300
|
+
|
|
301
|
+
if (!fs.existsSync(resolved)) {
|
|
302
|
+
fs.writeFileSync(resolved, renderScaffoldConfig(dbPath), 'utf8');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!dbPath) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const raw = TOML.parse(fs.readFileSync(resolved, 'utf8'));
|
|
311
|
+
upsertSectionValues(raw, ['app'], { db: dbPath });
|
|
275
312
|
writeToml(resolved, raw);
|
|
276
313
|
}
|
|
277
314
|
|
|
@@ -286,23 +323,34 @@ function scaffoldBuiltinDefaults(providerId) {
|
|
|
286
323
|
};
|
|
287
324
|
}
|
|
288
325
|
|
|
326
|
+
const PROVIDER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
327
|
+
|
|
328
|
+
function assertValidProviderId(providerId) {
|
|
329
|
+
const normalized = String(providerId || '').trim();
|
|
330
|
+
if (!normalized || !PROVIDER_ID_RE.test(normalized)) {
|
|
331
|
+
throw new Error(`Invalid provider id: ${providerId}`);
|
|
332
|
+
}
|
|
333
|
+
return normalized;
|
|
334
|
+
}
|
|
335
|
+
|
|
289
336
|
export function scaffoldProviderConfig(configPath, providerId) {
|
|
337
|
+
const normalizedProviderId = assertValidProviderId(providerId);
|
|
290
338
|
const resolved = path.resolve(configPath);
|
|
291
339
|
const raw = fs.existsSync(resolved) ? TOML.parse(fs.readFileSync(resolved, 'utf8')) : {};
|
|
292
340
|
|
|
293
|
-
const builtinDefaults = scaffoldBuiltinDefaults(
|
|
341
|
+
const builtinDefaults = scaffoldBuiltinDefaults(normalizedProviderId);
|
|
294
342
|
if (builtinDefaults) {
|
|
295
|
-
const sectionRaw = section(raw,
|
|
343
|
+
const sectionRaw = section(raw, normalizedProviderId);
|
|
296
344
|
const merged = { ...builtinDefaults, ...sectionRaw, enabled: true };
|
|
297
|
-
upsertSectionValues(raw, [
|
|
345
|
+
upsertSectionValues(raw, [normalizedProviderId], merged);
|
|
298
346
|
} else {
|
|
299
347
|
const pluginsRaw = section(raw, 'plugins');
|
|
300
|
-
const pluginRaw = section(pluginsRaw,
|
|
348
|
+
const pluginRaw = section(pluginsRaw, normalizedProviderId);
|
|
301
349
|
const merged = { ...pluginRaw, enabled: true };
|
|
302
350
|
if (!pluginRaw.module) {
|
|
303
351
|
merged.module = pluginRaw.module ?? null;
|
|
304
352
|
}
|
|
305
|
-
upsertSectionValues(raw, ['plugins',
|
|
353
|
+
upsertSectionValues(raw, ['plugins', normalizedProviderId], merged);
|
|
306
354
|
}
|
|
307
355
|
|
|
308
356
|
writeToml(resolved, raw);
|
package/src/db.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import Database from 'better-sqlite3';
|
|
3
4
|
import {
|
|
@@ -70,13 +71,18 @@ function normalizeWatermark(value) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
export class HealthSyncDb {
|
|
73
|
-
constructor(dbPath) {
|
|
74
|
+
constructor(dbPath, options = {}) {
|
|
74
75
|
this.path = path.resolve(dbPath);
|
|
76
|
+
this.credsPath = path.resolve(
|
|
77
|
+
options.credsPath || path.join(path.dirname(this.path), '.health-sync.creds'),
|
|
78
|
+
);
|
|
75
79
|
this.conn = new Database(this.path);
|
|
76
80
|
this.conn.pragma('journal_mode = WAL');
|
|
77
81
|
this.conn.pragma('foreign_keys = ON');
|
|
78
82
|
this._runStatsStack = [];
|
|
79
83
|
this._transactionDepth = 0;
|
|
84
|
+
this._oauthMigrated = false;
|
|
85
|
+
this._credsParseWarned = false;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
close() {
|
|
@@ -148,6 +154,7 @@ export class HealthSyncDb {
|
|
|
148
154
|
`);
|
|
149
155
|
|
|
150
156
|
this._normalizeLegacyTimestamps();
|
|
157
|
+
this._migrateOAuthTokensToCredsFile();
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
_normalizeLegacyTimestamps() {
|
|
@@ -178,6 +185,176 @@ export class HealthSyncDb {
|
|
|
178
185
|
}
|
|
179
186
|
}
|
|
180
187
|
|
|
188
|
+
_readCredsDoc() {
|
|
189
|
+
if (!fs.existsSync(this.credsPath)) {
|
|
190
|
+
return { version: 1, tokens: {} };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(fs.readFileSync(this.credsPath, 'utf8'));
|
|
195
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
196
|
+
return { version: 1, tokens: {} };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const tokens = (parsed.tokens && typeof parsed.tokens === 'object' && !Array.isArray(parsed.tokens))
|
|
200
|
+
? parsed.tokens
|
|
201
|
+
: {};
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
version: 1,
|
|
205
|
+
updatedAt: normalizeTimestamp(parsed.updatedAt || parsed.updated_at) || null,
|
|
206
|
+
tokens,
|
|
207
|
+
};
|
|
208
|
+
} catch {
|
|
209
|
+
if (!this._credsParseWarned) {
|
|
210
|
+
console.warn(`Ignoring invalid JSON in ${this.credsPath}`);
|
|
211
|
+
this._credsParseWarned = true;
|
|
212
|
+
}
|
|
213
|
+
return { version: 1, tokens: {} };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_writeCredsDoc(doc) {
|
|
218
|
+
const normalized = {
|
|
219
|
+
version: 1,
|
|
220
|
+
updatedAt: utcNowIso(),
|
|
221
|
+
tokens: doc?.tokens && typeof doc.tokens === 'object' && !Array.isArray(doc.tokens)
|
|
222
|
+
? doc.tokens
|
|
223
|
+
: {},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
fs.mkdirSync(path.dirname(this.credsPath), { recursive: true });
|
|
227
|
+
const tempPath = `${this.credsPath}.tmp-${process.pid}-${Date.now()}`;
|
|
228
|
+
fs.writeFileSync(tempPath, `${stableJsonStringify(normalized)}\n`, {
|
|
229
|
+
encoding: 'utf8',
|
|
230
|
+
mode: 0o600,
|
|
231
|
+
});
|
|
232
|
+
fs.renameSync(tempPath, this.credsPath);
|
|
233
|
+
try {
|
|
234
|
+
fs.chmodSync(this.credsPath, 0o600);
|
|
235
|
+
} catch {
|
|
236
|
+
// Ignore chmod failures on filesystems that do not support POSIX modes.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_normalizeStoredToken(provider, rawToken, contextLabel = `.health-sync.creds.${provider}`) {
|
|
241
|
+
if (!rawToken || typeof rawToken !== 'object' || Array.isArray(rawToken)) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const accessTokenValue = rawToken.accessToken ?? rawToken.access_token;
|
|
246
|
+
if (accessTokenValue === null || accessTokenValue === undefined || String(accessTokenValue).trim() === '') {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let extra = rawToken.extra ?? null;
|
|
251
|
+
if (extra === null && typeof rawToken.extra_json === 'string') {
|
|
252
|
+
extra = jsonLoadsOrNull(rawToken.extra_json, `${contextLabel}.extra_json`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
provider,
|
|
257
|
+
accessToken: String(accessTokenValue),
|
|
258
|
+
refreshToken: rawToken.refreshToken ?? rawToken.refresh_token ?? null,
|
|
259
|
+
tokenType: rawToken.tokenType ?? rawToken.token_type ?? null,
|
|
260
|
+
scope: rawToken.scope ?? null,
|
|
261
|
+
expiresAt: normalizeTimestamp(rawToken.expiresAt ?? rawToken.expires_at),
|
|
262
|
+
obtainedAt: normalizeTimestamp(rawToken.obtainedAt ?? rawToken.obtained_at),
|
|
263
|
+
extra,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_listLegacyOAuthTokens() {
|
|
268
|
+
let rows = [];
|
|
269
|
+
try {
|
|
270
|
+
rows = this.conn.prepare('SELECT * FROM oauth_tokens ORDER BY provider ASC').all();
|
|
271
|
+
} catch {
|
|
272
|
+
rows = [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const out = {};
|
|
276
|
+
for (const row of rows) {
|
|
277
|
+
const normalized = this._normalizeStoredToken(
|
|
278
|
+
row.provider,
|
|
279
|
+
{
|
|
280
|
+
access_token: row.access_token,
|
|
281
|
+
refresh_token: row.refresh_token,
|
|
282
|
+
token_type: row.token_type,
|
|
283
|
+
scope: row.scope,
|
|
284
|
+
expires_at: row.expires_at,
|
|
285
|
+
obtained_at: row.obtained_at,
|
|
286
|
+
extra_json: row.extra_json,
|
|
287
|
+
},
|
|
288
|
+
`oauth_tokens.${row.provider}`,
|
|
289
|
+
);
|
|
290
|
+
if (normalized) {
|
|
291
|
+
out[row.provider] = normalized;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_upsertCredsToken(provider, token) {
|
|
299
|
+
const doc = this._readCredsDoc();
|
|
300
|
+
const tokens = {
|
|
301
|
+
...(doc.tokens || {}),
|
|
302
|
+
[provider]: {
|
|
303
|
+
accessToken: token.accessToken,
|
|
304
|
+
refreshToken: token.refreshToken,
|
|
305
|
+
tokenType: token.tokenType,
|
|
306
|
+
scope: token.scope,
|
|
307
|
+
expiresAt: token.expiresAt,
|
|
308
|
+
obtainedAt: token.obtainedAt,
|
|
309
|
+
extra: token.extra,
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
this._writeCredsDoc({ ...doc, tokens });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
_migrateOAuthTokensToCredsFile() {
|
|
316
|
+
if (this._oauthMigrated) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
this._oauthMigrated = true;
|
|
320
|
+
|
|
321
|
+
const legacyTokens = this._listLegacyOAuthTokens();
|
|
322
|
+
if (!Object.keys(legacyTokens).length) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const doc = this._readCredsDoc();
|
|
327
|
+
const tokens = { ...(doc.tokens || {}) };
|
|
328
|
+
let changed = false;
|
|
329
|
+
|
|
330
|
+
for (const [provider, token] of Object.entries(legacyTokens)) {
|
|
331
|
+
const existing = this._normalizeStoredToken(provider, tokens[provider]);
|
|
332
|
+
if (existing) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
tokens[provider] = {
|
|
336
|
+
accessToken: token.accessToken,
|
|
337
|
+
refreshToken: token.refreshToken,
|
|
338
|
+
tokenType: token.tokenType,
|
|
339
|
+
scope: token.scope,
|
|
340
|
+
expiresAt: token.expiresAt,
|
|
341
|
+
obtainedAt: token.obtainedAt,
|
|
342
|
+
extra: token.extra,
|
|
343
|
+
};
|
|
344
|
+
changed = true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const nextDoc = changed ? { ...doc, tokens } : doc;
|
|
348
|
+
if (changed) {
|
|
349
|
+
this._writeCredsDoc(nextDoc);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const migratedProviders = Object.keys(legacyTokens)
|
|
353
|
+
.filter((provider) => this._normalizeStoredToken(provider, nextDoc.tokens?.[provider]));
|
|
354
|
+
for (const provider of migratedProviders) {
|
|
355
|
+
this.conn.prepare('DELETE FROM oauth_tokens WHERE provider = ?').run(provider);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
181
358
|
_incrementRunStats(stats, op) {
|
|
182
359
|
if (!stats) {
|
|
183
360
|
return;
|
|
@@ -356,22 +533,43 @@ export class HealthSyncDb {
|
|
|
356
533
|
}
|
|
357
534
|
|
|
358
535
|
getOAuthToken(provider) {
|
|
536
|
+
this._migrateOAuthTokensToCredsFile();
|
|
537
|
+
|
|
538
|
+
const fromCreds = this._normalizeStoredToken(
|
|
539
|
+
provider,
|
|
540
|
+
this._readCredsDoc().tokens?.[provider],
|
|
541
|
+
);
|
|
542
|
+
if (fromCreds) {
|
|
543
|
+
return fromCreds;
|
|
544
|
+
}
|
|
545
|
+
|
|
359
546
|
const row = this.conn
|
|
360
547
|
.prepare('SELECT * FROM oauth_tokens WHERE provider = ?')
|
|
361
548
|
.get(provider);
|
|
362
549
|
if (!row) {
|
|
363
550
|
return null;
|
|
364
551
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
552
|
+
|
|
553
|
+
const legacy = this._normalizeStoredToken(
|
|
554
|
+
provider,
|
|
555
|
+
{
|
|
556
|
+
access_token: row.access_token,
|
|
557
|
+
refresh_token: row.refresh_token,
|
|
558
|
+
token_type: row.token_type,
|
|
559
|
+
scope: row.scope,
|
|
560
|
+
expires_at: row.expires_at,
|
|
561
|
+
obtained_at: row.obtained_at,
|
|
562
|
+
extra_json: row.extra_json,
|
|
563
|
+
},
|
|
564
|
+
`oauth_tokens.${provider}`,
|
|
565
|
+
);
|
|
566
|
+
if (!legacy) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
this._upsertCredsToken(provider, legacy);
|
|
571
|
+
this.conn.prepare('DELETE FROM oauth_tokens WHERE provider = ?').run(provider);
|
|
572
|
+
return legacy;
|
|
375
573
|
}
|
|
376
574
|
|
|
377
575
|
setOAuthToken(provider, {
|
|
@@ -382,36 +580,24 @@ export class HealthSyncDb {
|
|
|
382
580
|
expiresAt = null,
|
|
383
581
|
extra = null,
|
|
384
582
|
}) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
) VALUES (
|
|
393
|
-
@provider, @access_token, @refresh_token, @token_type, @scope,
|
|
394
|
-
@expires_at, @obtained_at, @extra_json
|
|
395
|
-
)
|
|
396
|
-
ON CONFLICT(provider)
|
|
397
|
-
DO UPDATE SET
|
|
398
|
-
access_token = excluded.access_token,
|
|
399
|
-
refresh_token = excluded.refresh_token,
|
|
400
|
-
token_type = excluded.token_type,
|
|
401
|
-
scope = excluded.scope,
|
|
402
|
-
expires_at = excluded.expires_at,
|
|
403
|
-
obtained_at = excluded.obtained_at,
|
|
404
|
-
extra_json = excluded.extra_json
|
|
405
|
-
`).run({
|
|
583
|
+
if (accessToken === null || accessToken === undefined || String(accessToken).trim() === '') {
|
|
584
|
+
throw new Error('accessToken is required');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
this._migrateOAuthTokensToCredsFile();
|
|
588
|
+
|
|
589
|
+
const token = {
|
|
406
590
|
provider,
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
scope,
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
591
|
+
accessToken: String(accessToken),
|
|
592
|
+
refreshToken: refreshToken === undefined ? null : refreshToken,
|
|
593
|
+
tokenType: tokenType === undefined ? null : tokenType,
|
|
594
|
+
scope: scope === undefined ? null : scope,
|
|
595
|
+
expiresAt: normalizeTimestamp(expiresAt),
|
|
596
|
+
obtainedAt: utcNowIso(),
|
|
597
|
+
extra: extra === undefined ? null : extra,
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
this._upsertCredsToken(provider, token);
|
|
415
601
|
}
|
|
416
602
|
|
|
417
603
|
startSyncRun(provider, resource, watermarkBefore = null) {
|
|
@@ -596,8 +782,8 @@ export class HealthSyncDb {
|
|
|
596
782
|
}
|
|
597
783
|
}
|
|
598
784
|
|
|
599
|
-
export function openDb(dbPath) {
|
|
600
|
-
const db = new HealthSyncDb(dbPath);
|
|
785
|
+
export function openDb(dbPath, options = {}) {
|
|
786
|
+
const db = new HealthSyncDb(dbPath, options);
|
|
601
787
|
db.init();
|
|
602
788
|
return db;
|
|
603
789
|
}
|
package/src/util.js
CHANGED
|
@@ -157,6 +157,19 @@ export function parseRetryAfterSeconds(retryAfter) {
|
|
|
157
157
|
return Math.max(1, Math.ceil((retryAt - Date.now()) / 1000));
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
let requestJsonVerbose = false;
|
|
161
|
+
|
|
162
|
+
export function setRequestJsonVerbose(enabled) {
|
|
163
|
+
const previous = requestJsonVerbose;
|
|
164
|
+
requestJsonVerbose = Boolean(enabled);
|
|
165
|
+
return previous;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function logRequestJson(message) {
|
|
169
|
+
if (requestJsonVerbose) {
|
|
170
|
+
console.log(message);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
160
173
|
function buildHttpError(method, url, response, parsedBody, rawText) {
|
|
161
174
|
const detailCandidates = [];
|
|
162
175
|
if (parsedBody && typeof parsedBody === 'object') {
|
|
@@ -233,6 +246,7 @@ export async function requestJson(url, options = {}) {
|
|
|
233
246
|
|
|
234
247
|
let lastError = null;
|
|
235
248
|
const maxAttempts = Math.max(1, retries);
|
|
249
|
+
const requestLabel = `${method.toUpperCase()} ${target.toString()}`;
|
|
236
250
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
237
251
|
const controller = new AbortController();
|
|
238
252
|
const timeout = setTimeout(() => {
|
|
@@ -240,6 +254,7 @@ export async function requestJson(url, options = {}) {
|
|
|
240
254
|
}, timeoutMs);
|
|
241
255
|
|
|
242
256
|
try {
|
|
257
|
+
logRequestJson(`[http] -> ${requestLabel} (attempt ${attempt}/${maxAttempts})`);
|
|
243
258
|
const response = await fetch(target, {
|
|
244
259
|
method,
|
|
245
260
|
headers: requestHeaders,
|
|
@@ -248,6 +263,7 @@ export async function requestJson(url, options = {}) {
|
|
|
248
263
|
});
|
|
249
264
|
|
|
250
265
|
clearTimeout(timeout);
|
|
266
|
+
logRequestJson(`[http] <- ${response.status} ${response.statusText} ${requestLabel}`);
|
|
251
267
|
|
|
252
268
|
const rawText = await response.text();
|
|
253
269
|
let parsedBody = null;
|
|
@@ -266,6 +282,7 @@ export async function requestJson(url, options = {}) {
|
|
|
266
282
|
const delayMs = retryAfterSeconds !== null
|
|
267
283
|
? Math.min(60000, Math.max(1000, retryAfterSeconds * 1000))
|
|
268
284
|
: Math.min(60000, retryBackoffMs * (2 ** (attempt - 1)));
|
|
285
|
+
logRequestJson(`[http] retry in ${delayMs}ms (${requestLabel})`);
|
|
269
286
|
await sleep(delayMs);
|
|
270
287
|
continue;
|
|
271
288
|
}
|
|
@@ -296,6 +313,7 @@ export async function requestJson(url, options = {}) {
|
|
|
296
313
|
|| error?.cause?.code === 'ETIMEDOUT';
|
|
297
314
|
if (attempt < maxAttempts && retryable) {
|
|
298
315
|
const delayMs = Math.min(60000, retryBackoffMs * (2 ** (attempt - 1)));
|
|
316
|
+
logRequestJson(`[http] error ${error?.message || String(error)}; retry in ${delayMs}ms (${requestLabel})`);
|
|
299
317
|
await sleep(delayMs);
|
|
300
318
|
continue;
|
|
301
319
|
}
|