catence 0.1.0 → 0.2.0-beta.10
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/README.md +239 -269
- package/config.example.json +32 -20
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +48 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +42 -0
- package/dist/core/runtime/catalog.d.ts +41 -0
- package/dist/core/runtime/catalog.js +121 -0
- package/dist/core/runtime/configuration.d.ts +1 -1
- package/dist/core/runtime/configuration.js +3 -3
- package/dist/core/runtime/secrets.d.ts +24 -0
- package/dist/core/runtime/secrets.js +68 -0
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/management.d.ts +2 -0
- package/dist/elt/application/management.js +197 -18
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +4 -3
- package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
- package/dist/elt/ingestion/providers/intervals.js +4 -1
- package/dist/elt/ingestion/providers/strava/service.js +9 -2
- package/dist/elt/storage/database.d.ts +7 -2
- package/dist/elt/storage/database.js +81 -4
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/interfaces/cli/commands.js +201 -46
- package/dist/interfaces/http/cli-options.d.ts +2 -2
- package/dist/interfaces/http/cli-options.js +10 -10
- package/dist/interfaces/http/server.d.ts +4 -1
- package/dist/interfaces/http/server.js +31 -4
- package/dist/interfaces/mcp/cli-options.d.ts +2 -2
- package/dist/interfaces/mcp/cli-options.js +14 -14
- package/dist/interfaces/mcp/main.js +12 -5
- package/dist/interfaces/mcp/server.d.ts +2 -2
- package/dist/interfaces/mcp/server.js +156 -98
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +4 -1
- package/docs/deployment.md +338 -0
- package/docs/docker-deployment.md +232 -0
- package/docs/llm-providers.md +467 -0
- package/package.json +6 -2
- package/python/catence/providers/garmin/cli.py +30 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/worker.py +132 -5
- package/scripts/discover-opencode-go.mjs +210 -0
- package/scripts/discover-opencode-zen.mjs +218 -0
|
@@ -2,6 +2,7 @@ import { ReadOnlyRepository } from '../../core/query/repository.js';
|
|
|
2
2
|
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
3
3
|
import type { QueryBindings, WriteDataStore } from '../../contracts/storage.js';
|
|
4
4
|
import type { Provider } from '../../contracts/staging.js';
|
|
5
|
+
import { type InterruptedRuns, type SyncProgressSnapshot, type SyncProgressState } from '../../contracts/progress.js';
|
|
5
6
|
export declare class CatenceDatabase implements WriteDataStore {
|
|
6
7
|
private readonly instance;
|
|
7
8
|
private readonly connection;
|
|
@@ -16,6 +17,10 @@ export declare class CatenceDatabase implements WriteDataStore {
|
|
|
16
17
|
interrupt(): void;
|
|
17
18
|
beginRun(provider: Provider, fromDate: string, runId?: `${string}-${string}-${string}-${string}-${string}`): Promise<string>;
|
|
18
19
|
finishRun(runId: string): Promise<void>;
|
|
20
|
+
heartbeatRun(runId: string, progress: SyncProgressState): Promise<void>;
|
|
21
|
+
markRunInterrupted(runId: string, stage?: string): Promise<void>;
|
|
22
|
+
interruptStaleSyncRuns(timeoutMs?: number): Promise<InterruptedRuns>;
|
|
23
|
+
syncProgress(): Promise<SyncProgressSnapshot>;
|
|
19
24
|
addError(runId: string, provider: Provider, endpoint: string, remoteId: string | null, message: string, retryable: boolean): Promise<void>;
|
|
20
25
|
status(): Promise<Record<string, unknown>>;
|
|
21
26
|
getRun(runId: string): Promise<{
|
|
@@ -66,9 +71,9 @@ export declare class ReadOnlyCatenceDatabase {
|
|
|
66
71
|
interrupt(): void;
|
|
67
72
|
private withConnection;
|
|
68
73
|
}
|
|
69
|
-
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
70
|
-
export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;
|
|
71
74
|
export declare class ReadOnlyDatabaseError extends Error {
|
|
72
75
|
readonly code: 'data_sync_in_progress' | 'data_unavailable';
|
|
73
76
|
constructor(code: 'data_sync_in_progress' | 'data_unavailable', message: string);
|
|
74
77
|
}
|
|
78
|
+
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
79
|
+
export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;
|
|
@@ -2,6 +2,7 @@ import { DuckDBInstance } from '@duckdb/node-api';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { ReadOnlyRepository } from '../../core/query/repository.js';
|
|
5
|
+
import { SYNC_STAGES, STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
|
|
5
6
|
import { migrations } from './migrations.js';
|
|
6
7
|
import { json } from './sql.js';
|
|
7
8
|
export class CatenceDatabase {
|
|
@@ -81,6 +82,66 @@ export class CatenceDatabase {
|
|
|
81
82
|
await this.run(`UPDATE sync_runs SET status = CASE WHEN error_count > 0 THEN 'completed_with_errors' ELSE 'completed' END, completed_at = now() WHERE run_id = $runId`, { runId });
|
|
82
83
|
await this.run("UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context'");
|
|
83
84
|
}
|
|
85
|
+
async heartbeatRun(runId, progress) {
|
|
86
|
+
await this.run(`INSERT INTO sync_run_progress (run_id, provider, stage, current_step, completed_units, total_units, percent, elapsed_seconds, estimated_remaining_seconds, heartbeat_at, detail_json)
|
|
87
|
+
VALUES ($runId, $provider, $stage, $currentStep, $completedUnits, $totalUnits, $percent, $elapsedSeconds, $estimatedRemainingSeconds, now(), '{}')
|
|
88
|
+
ON CONFLICT (run_id) DO UPDATE SET
|
|
89
|
+
provider = EXCLUDED.provider,
|
|
90
|
+
stage = EXCLUDED.stage,
|
|
91
|
+
current_step = EXCLUDED.current_step,
|
|
92
|
+
completed_units = EXCLUDED.completed_units,
|
|
93
|
+
total_units = EXCLUDED.total_units,
|
|
94
|
+
percent = EXCLUDED.percent,
|
|
95
|
+
elapsed_seconds = EXCLUDED.elapsed_seconds,
|
|
96
|
+
estimated_remaining_seconds = EXCLUDED.estimated_remaining_seconds,
|
|
97
|
+
heartbeat_at = now(),
|
|
98
|
+
detail_json = '{}'`, {
|
|
99
|
+
runId,
|
|
100
|
+
provider: progress.provider,
|
|
101
|
+
stage: progress.stage,
|
|
102
|
+
currentStep: progress.currentStep,
|
|
103
|
+
completedUnits: progress.completedUnits,
|
|
104
|
+
totalUnits: progress.totalUnits,
|
|
105
|
+
percent: progress.percentComplete,
|
|
106
|
+
elapsedSeconds: progress.elapsedSeconds,
|
|
107
|
+
estimatedRemainingSeconds: progress.estimatedRemainingSeconds,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async markRunInterrupted(runId, stage = 'interrupted') {
|
|
111
|
+
await this.run("UPDATE sync_runs SET status = 'interrupted', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId });
|
|
112
|
+
await this.run('UPDATE sync_run_progress SET stage = $stage, heartbeat_at = now() WHERE run_id = $runId', { runId, stage });
|
|
113
|
+
}
|
|
114
|
+
async interruptStaleSyncRuns(timeoutMs = STALE_RUN_TIMEOUT_MS) {
|
|
115
|
+
const cutoff = new Date(Date.now() - timeoutMs).toISOString();
|
|
116
|
+
const stale = await this.rows(`SELECT run_id FROM sync_runs WHERE status = 'running' AND started_at < $cutoff`, { cutoff });
|
|
117
|
+
for (const row of stale) {
|
|
118
|
+
await this.run("UPDATE sync_runs SET status = 'timed_out', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId: row.run_id });
|
|
119
|
+
await this.run("UPDATE sync_run_progress SET stage = 'timed_out', heartbeat_at = now() WHERE run_id = $runId", { runId: row.run_id });
|
|
120
|
+
}
|
|
121
|
+
return { runIds: stale.map((row) => row.run_id) };
|
|
122
|
+
}
|
|
123
|
+
async syncProgress() {
|
|
124
|
+
const running = await this.rows(`SELECT runs.run_id, runs.provider,
|
|
125
|
+
coalesce(progress.stage, 'starting') AS stage,
|
|
126
|
+
progress.current_step AS current_step,
|
|
127
|
+
coalesce(progress.completed_units, 0) AS completed_units,
|
|
128
|
+
progress.total_units AS total_units,
|
|
129
|
+
coalesce(progress.percent, 0) AS percent,
|
|
130
|
+
coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
|
|
131
|
+
progress.estimated_remaining_seconds AS estimated_remaining_seconds,
|
|
132
|
+
cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
|
|
133
|
+
FROM sync_runs AS runs
|
|
134
|
+
LEFT JOIN sync_run_progress AS progress USING (run_id)
|
|
135
|
+
WHERE runs.status = 'running'
|
|
136
|
+
ORDER BY runs.started_at DESC`);
|
|
137
|
+
const recent = await this.rows(`SELECT progress.run_id, progress.provider, progress.stage, progress.current_step,
|
|
138
|
+
progress.completed_units, progress.total_units, progress.percent, progress.elapsed_seconds,
|
|
139
|
+
progress.estimated_remaining_seconds, cast(progress.heartbeat_at AS VARCHAR) AS heartbeat_at
|
|
140
|
+
FROM sync_run_progress AS progress
|
|
141
|
+
ORDER BY progress.heartbeat_at DESC
|
|
142
|
+
LIMIT 10`);
|
|
143
|
+
return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
|
|
144
|
+
}
|
|
84
145
|
async addError(runId, provider, endpoint, remoteId, message, retryable) {
|
|
85
146
|
await this.run(`INSERT INTO normalization_errors (error_id, run_id, provider, endpoint, remote_id, message, retryable)
|
|
86
147
|
VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
|
|
@@ -94,9 +155,11 @@ export class CatenceDatabase {
|
|
|
94
155
|
const rawObjects = await this.rows('SELECT count(*)::INTEGER AS count FROM raw_objects');
|
|
95
156
|
const activities = await this.rows('SELECT count(*)::INTEGER AS count FROM activities');
|
|
96
157
|
const streams = await this.rows('SELECT count(*)::INTEGER AS count FROM stream_manifest');
|
|
158
|
+
const errorDetails = await this.rows('SELECT run_id, provider, endpoint, remote_id, message, cast(created_at AS VARCHAR) AS created_at FROM normalization_errors WHERE resolved_at IS NULL ORDER BY created_at DESC LIMIT 100');
|
|
97
159
|
const cursors = await this.rows('SELECT provider, cursor_name, cast(covered_through_date AS VARCHAR) AS covered_through_date, cast(latest_source_date AS VARCHAR) AS latest_source_date, lookback_days, last_successful_run_id, cast(last_completed_at AS VARCHAR) AS last_completed_at, status FROM sync_cursors ORDER BY provider, cursor_name');
|
|
98
160
|
return {
|
|
99
161
|
runs, unresolvedErrors: errors[0]?.count ?? 0, rawObjects: rawObjects[0]?.count ?? 0, activities: activities[0]?.count ?? 0, streams: streams[0]?.count ?? 0,
|
|
162
|
+
errors: errorDetails,
|
|
100
163
|
cursors: cursors.map((cursor) => ({ ...cursor, next_from_date: subtractDays(cursor.covered_through_date, Number(cursor.lookback_days)) })),
|
|
101
164
|
};
|
|
102
165
|
}
|
|
@@ -281,10 +344,6 @@ export class ReadOnlyCatenceDatabase {
|
|
|
281
344
|
}
|
|
282
345
|
}
|
|
283
346
|
}
|
|
284
|
-
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
285
|
-
export async function openReadOnlyRepository(paths) {
|
|
286
|
-
return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
|
|
287
|
-
}
|
|
288
347
|
export class ReadOnlyDatabaseError extends Error {
|
|
289
348
|
code;
|
|
290
349
|
constructor(code, message) {
|
|
@@ -293,4 +352,22 @@ export class ReadOnlyDatabaseError extends Error {
|
|
|
293
352
|
this.name = 'ReadOnlyDatabaseError';
|
|
294
353
|
}
|
|
295
354
|
}
|
|
355
|
+
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
356
|
+
export async function openReadOnlyRepository(paths) {
|
|
357
|
+
return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
|
|
358
|
+
}
|
|
359
|
+
function toSyncProgressState(row) {
|
|
360
|
+
return {
|
|
361
|
+
runId: row.run_id,
|
|
362
|
+
provider: row.provider,
|
|
363
|
+
stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
|
|
364
|
+
currentStep: row.current_step,
|
|
365
|
+
completedUnits: Math.max(0, Number(row.completed_units) || 0),
|
|
366
|
+
totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
|
|
367
|
+
percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
|
|
368
|
+
elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
|
|
369
|
+
estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
|
|
370
|
+
heartbeatAt: row.heartbeat_at,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
296
373
|
//# sourceMappingURL=database.js.map
|
|
@@ -828,5 +828,29 @@ const swimFactsAndQualityMigration = {
|
|
|
828
828
|
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
829
829
|
`,
|
|
830
830
|
};
|
|
831
|
-
|
|
831
|
+
const syncRunProgressMigration = {
|
|
832
|
+
version: 14,
|
|
833
|
+
name: 'sync_run_progress_heartbeats',
|
|
834
|
+
sql: `
|
|
835
|
+
-- Live progress heartbeats for in-flight sync runs. Written by the Node
|
|
836
|
+
-- side from progress records streamed by provider workers; read-only
|
|
837
|
+
-- surfaces (status, progress CLI, MCP) join against sync_runs.
|
|
838
|
+
CREATE TABLE IF NOT EXISTS sync_run_progress (
|
|
839
|
+
run_id VARCHAR PRIMARY KEY,
|
|
840
|
+
provider VARCHAR NOT NULL,
|
|
841
|
+
stage VARCHAR NOT NULL,
|
|
842
|
+
current_step VARCHAR,
|
|
843
|
+
completed_units INTEGER NOT NULL DEFAULT 0,
|
|
844
|
+
total_units INTEGER,
|
|
845
|
+
percent DOUBLE NOT NULL DEFAULT 0,
|
|
846
|
+
elapsed_seconds DOUBLE NOT NULL DEFAULT 0,
|
|
847
|
+
estimated_remaining_seconds DOUBLE,
|
|
848
|
+
heartbeat_at TIMESTAMPTZ NOT NULL,
|
|
849
|
+
detail_json JSON NOT NULL DEFAULT '{}'
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
853
|
+
`,
|
|
854
|
+
};
|
|
855
|
+
export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration];
|
|
832
856
|
//# sourceMappingURL=migrations.js.map
|
|
@@ -1,33 +1,98 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
2
4
|
import { Command } from 'commander';
|
|
3
|
-
import {
|
|
5
|
+
import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
|
|
4
6
|
const program = new Command()
|
|
5
7
|
.name('catence-data')
|
|
6
|
-
.description('
|
|
7
|
-
.option('--
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
.description('Athlete-scoped Catence data setup, extraction, and normalization.')
|
|
9
|
+
.option('--home <directory>', 'Catence catalog home directory', defaultCatalogHome())
|
|
10
|
+
.option('--athlete <id>', 'athlete ID for a data operation');
|
|
11
|
+
function currentCatalog() {
|
|
12
|
+
return resolveCatalogPaths(program.opts().home);
|
|
10
13
|
}
|
|
11
|
-
function
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return resolvePaths(dataDir);
|
|
14
|
+
async function currentAthlete() {
|
|
15
|
+
const athleteId = program.opts().athlete;
|
|
16
|
+
if (!athleteId)
|
|
17
|
+
throw new Error('--athlete is required for this command. Run catence-data athlete list to inspect the catalog.');
|
|
18
|
+
return resolveAthlete(currentCatalog(), athleteId);
|
|
17
19
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
async function prompt(question) {
|
|
21
|
+
if (!input.isTTY)
|
|
22
|
+
throw new Error(`Missing required setup value. Re-run with the matching option (${question}).`);
|
|
23
|
+
const terminal = createInterface({ input, output });
|
|
24
|
+
try {
|
|
25
|
+
return (await terminal.question(question)).trim();
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
terminal.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function stdinSecret() {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
for await (const chunk of input)
|
|
34
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
35
|
+
const value = Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
|
|
36
|
+
if (!value)
|
|
37
|
+
throw new Error('--value-stdin received an empty secret.');
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
program.command('setup')
|
|
41
|
+
.description('Create a home catalog and its first athlete store. Existing 0.1 stores must be re-synced.')
|
|
42
|
+
.option('--athlete <id>', 'first athlete ID')
|
|
43
|
+
.option('--label <label>', 'display label for the first athlete')
|
|
44
|
+
.action(async (options) => {
|
|
45
|
+
// Commander resolves the global --athlete option first when it appears
|
|
46
|
+
// after this subcommand, so accept either scope without making the setup
|
|
47
|
+
// command order-sensitive.
|
|
48
|
+
const athlete = options.athlete ?? program.opts().athlete ?? await prompt('First athlete ID (for example, alex): ');
|
|
49
|
+
const label = options.label ?? await prompt('First athlete display name: ');
|
|
50
|
+
const catalog = await initializeCatalog(currentCatalog(), { id: athlete, label });
|
|
51
|
+
process.stdout.write(`${JSON.stringify({ home: currentCatalog().root, catalog, message: 'Catence catalog is ready. Add provider values with catence-data secret set --athlete <id> --value-stdin, then run sync.' }, null, 2)}\n`);
|
|
52
|
+
});
|
|
53
|
+
const athleteCommand = program.command('athlete').description('Manage isolated athlete stores in this Catence catalog.');
|
|
54
|
+
athleteCommand.command('list').action(async () => {
|
|
55
|
+
process.stdout.write(`${JSON.stringify(await loadCatalog(currentCatalog()), null, 2)}\n`);
|
|
56
|
+
});
|
|
57
|
+
athleteCommand.command('add')
|
|
58
|
+
.requiredOption('--id <id>', 'stable athlete ID')
|
|
59
|
+
.requiredOption('--label <label>', 'display label')
|
|
60
|
+
.option('--default', 'make this the default athlete for Console setup')
|
|
61
|
+
.action(async (options) => {
|
|
62
|
+
process.stdout.write(`${JSON.stringify(await addAthlete(currentCatalog(), { id: options.id, label: options.label, setDefault: options.default }), null, 2)}\n`);
|
|
63
|
+
});
|
|
64
|
+
const secretCommand = program.command('secret').description('Write a provider value into one athlete’s owner-only secret store.');
|
|
65
|
+
secretCommand.command('set')
|
|
66
|
+
.requiredOption('--provider <provider>', 'garmin, intervals, or strava')
|
|
67
|
+
.requiredOption('--field <field>', 'provider secret field')
|
|
68
|
+
.requiredOption('--value-stdin', 'read the secret from stdin without putting it in shell history')
|
|
69
|
+
.action(async (options) => {
|
|
70
|
+
if (!['garmin', 'intervals', 'strava'].includes(options.provider))
|
|
71
|
+
throw new Error('--provider must be garmin, intervals, or strava.');
|
|
72
|
+
const { paths } = await currentAthlete();
|
|
73
|
+
await setAthleteSecret(paths, options.provider, options.field, await stdinSecret());
|
|
74
|
+
process.stdout.write(`${JSON.stringify({ athleteId: program.opts().athlete, provider: options.provider, field: options.field, stored: true }, null, 2)}\n`);
|
|
22
75
|
});
|
|
23
76
|
program.command('demo')
|
|
24
|
-
.description('Create a generated
|
|
77
|
+
.description('Create a generated demo athlete in an isolated demo catalog without contacting providers.')
|
|
25
78
|
.option('--days <days>', 'generated days of wellness and training data', '90')
|
|
26
79
|
.option('--seed <seed>', 'deterministic generator seed', '17')
|
|
27
80
|
.action(async (options) => {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
81
|
+
const explicitHome = process.argv.some((argument) => argument === '--home' || argument.startsWith('--home='));
|
|
82
|
+
const demoCatalog = resolveCatalogPaths(explicitHome ? currentCatalog().root : `${defaultCatalogHome()}-demo`);
|
|
83
|
+
let catalog;
|
|
84
|
+
try {
|
|
85
|
+
catalog = await loadCatalog(demoCatalog);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
catalog = await initializeCatalog(demoCatalog, { id: 'demo', label: 'Generated demo athlete' });
|
|
89
|
+
}
|
|
90
|
+
if (!catalog.athletes.some((athlete) => athlete.id === 'demo')) {
|
|
91
|
+
catalog = await addAthlete(demoCatalog, { id: 'demo', label: 'Generated demo athlete' });
|
|
92
|
+
}
|
|
93
|
+
const paths = athleteStorePaths(demoCatalog, 'demo');
|
|
94
|
+
const result = await createDemoStore(paths, { days: Number(options.days), seed: Number(options.seed) });
|
|
95
|
+
process.stdout.write(`${JSON.stringify({ ...result, home: demoCatalog.root, athleteId: 'demo' }, null, 2)}\n`);
|
|
31
96
|
});
|
|
32
97
|
program.command('sync')
|
|
33
98
|
.requiredOption('--provider <provider>', 'intervals, garmin, strava, or all')
|
|
@@ -36,23 +101,26 @@ program.command('sync')
|
|
|
36
101
|
.action(async (options) => {
|
|
37
102
|
if (!['intervals', 'garmin', 'strava', 'all'].includes(options.provider))
|
|
38
103
|
throw new Error('--provider must be intervals, garmin, strava, or all.');
|
|
39
|
-
|
|
104
|
+
const { paths } = await currentAthlete();
|
|
105
|
+
process.stdout.write(`${JSON.stringify(await syncData(paths, options.provider, options.from, true, options.refresh ?? false), null, 2)}\n`);
|
|
40
106
|
});
|
|
41
107
|
program.command('backfill')
|
|
42
108
|
.requiredOption('--from <date>', 'ISO date at which to begin backfill')
|
|
43
109
|
.option('--provider <provider>', 'intervals, garmin, strava, or all', 'all')
|
|
44
|
-
.option('--refresh', 're-fetch and upsert data already covered by the requested range
|
|
110
|
+
.option('--refresh', 're-fetch and upsert data already covered by the requested range')
|
|
45
111
|
.action(async (options) => {
|
|
46
112
|
if (!['intervals', 'garmin', 'strava', 'all'].includes(options.provider))
|
|
47
113
|
throw new Error('--provider must be intervals, garmin, strava, or all.');
|
|
48
|
-
|
|
114
|
+
const { paths } = await currentAthlete();
|
|
115
|
+
process.stdout.write(`${JSON.stringify(await syncData(paths, options.provider, options.from, false, options.refresh ?? false, true), null, 2)}\n`);
|
|
49
116
|
});
|
|
50
117
|
program.command('retry')
|
|
51
118
|
.requiredOption('--run <run-id>', 'failed run to retry')
|
|
52
119
|
.action(async (options) => {
|
|
53
|
-
|
|
120
|
+
const { paths } = await currentAthlete();
|
|
121
|
+
process.stdout.write(`${JSON.stringify(await retryDataSync(paths, options.run), null, 2)}\n`);
|
|
54
122
|
});
|
|
55
|
-
program.command('auth').description('Connect a source account
|
|
123
|
+
program.command('auth').description('Connect a source account using one athlete’s local credentials.')
|
|
56
124
|
.command('strava')
|
|
57
125
|
.option('--code <authorization-code>', 'OAuth authorization code returned by Strava')
|
|
58
126
|
.option('--redirect-uri <uri>', 'OAuth redirect URI; defaults depend on the selected flow')
|
|
@@ -64,38 +132,125 @@ program.command('auth').description('Connect a source account without storing cr
|
|
|
64
132
|
const timeoutSeconds = Number(options.timeoutSeconds);
|
|
65
133
|
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 900)
|
|
66
134
|
throw new Error('--timeout-seconds must be an integer between 1 and 900.');
|
|
135
|
+
const { paths } = await currentAthlete();
|
|
67
136
|
const redirectUri = options.redirectUri ?? (options.callback ? 'http://127.0.0.1:8765/strava/callback' : 'http://localhost');
|
|
68
137
|
const result = options.callback
|
|
69
|
-
? await connectStravaWithCallback(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
: await connectStrava(currentPaths(), options.code, redirectUri);
|
|
73
|
-
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
138
|
+
? await connectStravaWithCallback(paths, redirectUri, (url) => process.stderr.write(`Open this URL in your browser to connect Strava:\n${url}\nWaiting for the local callback…\n`), timeoutSeconds * 1_000)
|
|
139
|
+
: await connectStrava(paths, options.code, redirectUri);
|
|
140
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
74
141
|
});
|
|
75
|
-
program.command('disconnect').description('Remove
|
|
142
|
+
program.command('disconnect').description('Remove one athlete’s stored provider connection.')
|
|
76
143
|
.command('strava')
|
|
77
144
|
.action(async () => {
|
|
78
|
-
|
|
145
|
+
const { paths } = await currentAthlete();
|
|
146
|
+
process.stdout.write(`${JSON.stringify(await disconnectStravaAccount(paths), null, 2)}\n`);
|
|
79
147
|
});
|
|
80
|
-
const activityCommand = program.command('activity').description('Audit and correct logical activity links.');
|
|
81
|
-
activityCommand.command('link')
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
.action(async (options) => {
|
|
85
|
-
process.stdout.write(JSON.stringify(await linkActivity(currentPaths(), options.source, options.activity), null, 2) + "\n");
|
|
148
|
+
const activityCommand = program.command('activity').description('Audit and correct logical activity links for one athlete.');
|
|
149
|
+
activityCommand.command('link').requiredOption('--source <activity-source-id>').requiredOption('--activity <activity-id>').action(async (options) => {
|
|
150
|
+
const { paths } = await currentAthlete();
|
|
151
|
+
process.stdout.write(`${JSON.stringify(await linkActivity(paths, options.source, options.activity), null, 2)}\n`);
|
|
86
152
|
});
|
|
87
|
-
activityCommand.command('unlink')
|
|
88
|
-
|
|
89
|
-
.
|
|
90
|
-
process.stdout.write(JSON.stringify(await unlinkActivity(currentPaths(), options.source), null, 2) + "\n");
|
|
153
|
+
activityCommand.command('unlink').requiredOption('--source <activity-source-id>').action(async (options) => {
|
|
154
|
+
const { paths } = await currentAthlete();
|
|
155
|
+
process.stdout.write(`${JSON.stringify(await unlinkActivity(paths, options.source), null, 2)}\n`);
|
|
91
156
|
});
|
|
92
157
|
program.command('status').action(async () => {
|
|
93
|
-
|
|
158
|
+
const { paths } = await currentAthlete();
|
|
159
|
+
process.stdout.write(`${JSON.stringify(await dataStatus(paths), null, 2)}\n`);
|
|
94
160
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
161
|
+
function renderProgress(snapshot) {
|
|
162
|
+
const lines = snapshot.running.map((run) => {
|
|
163
|
+
const pct = run.percentComplete.toFixed(1).padStart(5);
|
|
164
|
+
const units = run.totalUnits !== null ? `${run.completedUnits}/${run.totalUnits}` : String(run.completedUnits);
|
|
165
|
+
const eta = run.estimatedRemainingSeconds !== null ? `${Math.round(run.estimatedRemainingSeconds)}s` : '?';
|
|
166
|
+
const step = run.currentStep ?? '';
|
|
167
|
+
return `${pct}% ${run.stage.padEnd(12)} ${units.padEnd(11)} ${eta.padEnd(6)} ${step}`;
|
|
168
|
+
});
|
|
169
|
+
return lines.length === 0 ? 'no active sync runs' : lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
program.command('progress')
|
|
172
|
+
.description('Show live progress for active sync runs.')
|
|
173
|
+
.option('--watch', 'refresh every few seconds until no sync runs are active')
|
|
174
|
+
.action(async (options) => {
|
|
175
|
+
const { paths } = await currentAthlete();
|
|
176
|
+
if (!options.watch) {
|
|
177
|
+
process.stdout.write(`${JSON.stringify(await syncProgress(paths), null, 2)}\n`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
while (true) {
|
|
181
|
+
const snapshot = await syncProgress(paths);
|
|
182
|
+
if (output.isTTY) {
|
|
183
|
+
output.write(`\x1b[2J\x1b[H${renderProgress(snapshot)}\n`);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`);
|
|
187
|
+
}
|
|
188
|
+
if (snapshot.running.length === 0)
|
|
189
|
+
break;
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
program.command('update')
|
|
194
|
+
.description('Check for and apply the newest Catence release on the tracked channel (runtime and Console).')
|
|
195
|
+
.option('--check', 'report available releases without changing anything')
|
|
196
|
+
.option('--channel <stable|beta>', 'release channel to track; defaults to the installed runtime channel')
|
|
197
|
+
.option('--runtime', 'only check or update the Catence runtime package')
|
|
198
|
+
.option('--console', 'only check or update the Catence Console package')
|
|
199
|
+
.action(async (options) => {
|
|
200
|
+
if (options.channel !== undefined && options.channel !== 'stable' && options.channel !== 'beta') {
|
|
201
|
+
throw new Error('--channel must be stable or beta.');
|
|
202
|
+
}
|
|
203
|
+
const channel = options.channel ?? channelForVersion(CATENCE_RUNTIME_VERSION);
|
|
204
|
+
const runtimeOnly = options.runtime ?? false;
|
|
205
|
+
const consoleOnly = options.console ?? false;
|
|
206
|
+
const updateRuntime = runtimeOnly || !consoleOnly;
|
|
207
|
+
const updateConsole = consoleOnly || !runtimeOnly;
|
|
208
|
+
const [npmDistTags, pypiReleases, consoleInstalled] = await Promise.all([
|
|
209
|
+
fetchNpmDistTags('catence'),
|
|
210
|
+
fetchPypiReleases('catence-console'),
|
|
211
|
+
readInstalledConsoleVersion(),
|
|
212
|
+
]);
|
|
213
|
+
const plan = planSelfUpdate({ runtimeVersion: CATENCE_RUNTIME_VERSION, channel, npmDistTags, pypiReleases, consoleInstalled });
|
|
214
|
+
const report = { channel, runtime: plan.runtime, console: plan.console };
|
|
215
|
+
if (options.check) {
|
|
216
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
217
|
+
process.exit(plan.ok ? 0 : 1);
|
|
218
|
+
}
|
|
219
|
+
const applied = [];
|
|
220
|
+
if (plan.runtime.updateAvailable && updateRuntime) {
|
|
221
|
+
const runtimeTarget = plan.runtime.target;
|
|
222
|
+
if (runtimeTarget === undefined)
|
|
223
|
+
throw new Error('No runtime update target is available.');
|
|
224
|
+
const npmPrefix = await npmGlobalPrefix();
|
|
225
|
+
if (isGlobalNpmInstall(import.meta.url, npmPrefix)) {
|
|
226
|
+
const exitCode = await runSelfUpdateCommand('npm', ['install', '--global', `catence@${runtimeTarget}`]);
|
|
227
|
+
if (exitCode !== 0)
|
|
228
|
+
throw new Error(`npm failed to install catence@${runtimeTarget} (exit ${exitCode}).`);
|
|
229
|
+
applied.push(`runtime: catence@${runtimeTarget}`);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
process.stderr.write(`Runtime is not a global npm install; run: npm install --global catence@${runtimeTarget}\n`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (plan.console.updateAvailable && updateConsole) {
|
|
236
|
+
const consoleTarget = plan.console.target;
|
|
237
|
+
if (consoleTarget === undefined)
|
|
238
|
+
throw new Error('No Console update target is available.');
|
|
239
|
+
if (await hasCommand('uv')) {
|
|
240
|
+
const exitCode = await runSelfUpdateCommand('uv', ['tool', 'install', '--upgrade', `catence-console==${consoleTarget}`]);
|
|
241
|
+
if (exitCode !== 0)
|
|
242
|
+
throw new Error(`uv failed to install catence-console==${consoleTarget} (exit ${exitCode}).`);
|
|
243
|
+
applied.push(`console: catence-console==${consoleTarget}`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
process.stderr.write(`uv is not installed; run: pip install --upgrade 'catence-console==${consoleTarget}'\n`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
process.stdout.write(`${JSON.stringify({ ...report, applied }, null, 2)}\n`);
|
|
250
|
+
});
|
|
251
|
+
program.command('build-retrieval-index').description('Build derived local retrieval context for one athlete.').action(async () => {
|
|
252
|
+
const { paths } = await currentAthlete();
|
|
253
|
+
process.stdout.write(`${JSON.stringify(await rebuildRetrievalIndex(paths), null, 2)}\n`);
|
|
99
254
|
});
|
|
100
255
|
await program.parseAsync();
|
|
101
256
|
//# sourceMappingURL=commands.js.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export type ServeCliOptions = {
|
|
2
|
-
|
|
2
|
+
home?: string;
|
|
3
3
|
host: string;
|
|
4
4
|
port: number;
|
|
5
5
|
allowedOrigins: string[];
|
|
6
6
|
help: boolean;
|
|
7
7
|
};
|
|
8
|
-
export declare const SERVE_USAGE = "Usage: catence serve [options]\n\nRun Catence as a local Streamable HTTP MCP server.\n\nOptions:\n --
|
|
8
|
+
export declare const SERVE_USAGE = "Usage: catence serve [options]\n\nRun Catence as a local Streamable HTTP MCP server.\n\nOptions:\n --home <directory> Catence catalog home. Defaults to CATENCE_HOME or ~/.catence.\n --host <host> Listener host. Defaults to 127.0.0.1.\n --port <port> Listener port. Defaults to 8787.\n --allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.\n -h, --help Show this help text.";
|
|
9
9
|
export declare function parseServeCliOptions(arguments_: readonly string[]): ServeCliOptions;
|
|
@@ -3,7 +3,7 @@ export const SERVE_USAGE = `Usage: catence serve [options]
|
|
|
3
3
|
Run Catence as a local Streamable HTTP MCP server.
|
|
4
4
|
|
|
5
5
|
Options:
|
|
6
|
-
--
|
|
6
|
+
--home <directory> Catence catalog home. Defaults to CATENCE_HOME or ~/.catence.
|
|
7
7
|
--host <host> Listener host. Defaults to 127.0.0.1.
|
|
8
8
|
--port <port> Listener port. Defaults to 8787.
|
|
9
9
|
--allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.
|
|
@@ -23,23 +23,23 @@ function parsePort(value) {
|
|
|
23
23
|
return port;
|
|
24
24
|
}
|
|
25
25
|
export function parseServeCliOptions(arguments_) {
|
|
26
|
-
let
|
|
26
|
+
let home;
|
|
27
27
|
let host = process.env.CATENCE_HTTP_HOST ?? '127.0.0.1';
|
|
28
28
|
let port = process.env.CATENCE_HTTP_PORT ? parsePort(process.env.CATENCE_HTTP_PORT) : 8787;
|
|
29
29
|
const allowedOrigins = [];
|
|
30
30
|
for (let index = 0; index < arguments_.length; index++) {
|
|
31
31
|
const argument = arguments_[index];
|
|
32
32
|
if (argument === '-h' || argument === '--help')
|
|
33
|
-
return {
|
|
34
|
-
if (argument === '--
|
|
35
|
-
|
|
33
|
+
return { home, host, port, allowedOrigins, help: true };
|
|
34
|
+
if (argument === '--home') {
|
|
35
|
+
home = requiredValue(arguments_, index, '--home');
|
|
36
36
|
index++;
|
|
37
37
|
continue;
|
|
38
38
|
}
|
|
39
|
-
if (argument.startsWith('--
|
|
40
|
-
|
|
41
|
-
if (!
|
|
42
|
-
throw new Error('--
|
|
39
|
+
if (argument.startsWith('--home=')) {
|
|
40
|
+
home = argument.slice('--home='.length);
|
|
41
|
+
if (!home)
|
|
42
|
+
throw new Error('--home requires a value.');
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
45
45
|
if (argument === '--host') {
|
|
@@ -76,6 +76,6 @@ export function parseServeCliOptions(arguments_) {
|
|
|
76
76
|
}
|
|
77
77
|
throw new Error(`Unknown option: ${argument}`);
|
|
78
78
|
}
|
|
79
|
-
return {
|
|
79
|
+
return { home, host, port, allowedOrigins, help: false };
|
|
80
80
|
}
|
|
81
81
|
//# sourceMappingURL=cli-options.js.map
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type Server } from 'node:http';
|
|
2
|
-
import { type CatencePaths } from '../../runtime/index.js';
|
|
2
|
+
import { type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
|
|
3
3
|
export type CatenceHttpServerOptions = {
|
|
4
|
+
/** A shared catalog for public servers. All data APIs then require athleteId. */
|
|
5
|
+
catalogPaths?: CatalogPaths;
|
|
6
|
+
/** Internal compatibility hook for tests and embedded single-store callers. */
|
|
4
7
|
paths?: CatencePaths;
|
|
5
8
|
allowedOrigins?: readonly string[];
|
|
6
9
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
-
import { catenceRuntimeHealth, DashboardSnapshotService, jsonSafe, openReadOnlyRepository,
|
|
3
|
+
import { catenceRuntimeHealth, DashboardSnapshotService, jsonSafe, loadCatalog, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths } from '../../runtime/index.js';
|
|
4
4
|
import { createCatenceMcpServer } from '../mcp/server.js';
|
|
5
5
|
const MAX_REQUEST_BYTES = 1_000_000;
|
|
6
6
|
function json(response, statusCode, value) {
|
|
@@ -74,6 +74,17 @@ async function dashboardSnapshot(paths, url) {
|
|
|
74
74
|
await repository.close();
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
async function dashboardPaths(catalogPaths, staticPaths, url) {
|
|
78
|
+
if (catalogPaths) {
|
|
79
|
+
const athleteId = url.searchParams.get('athleteId');
|
|
80
|
+
if (!athleteId)
|
|
81
|
+
throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
|
|
82
|
+
return (await resolveAthlete(catalogPaths, athleteId)).paths;
|
|
83
|
+
}
|
|
84
|
+
if (staticPaths)
|
|
85
|
+
return staticPaths;
|
|
86
|
+
throw new Error('No Catence data store is configured.');
|
|
87
|
+
}
|
|
77
88
|
async function handleMcpRequest(request, response, paths) {
|
|
78
89
|
if (request.method !== 'POST') {
|
|
79
90
|
methodNotAllowed(response);
|
|
@@ -129,7 +140,8 @@ async function handleMcpRequest(request, response, paths) {
|
|
|
129
140
|
* process-wide MCP session state or a second query implementation.
|
|
130
141
|
*/
|
|
131
142
|
export function createCatenceHttpServer(options = {}) {
|
|
132
|
-
const
|
|
143
|
+
const catalogPaths = options.catalogPaths ?? (options.paths ? null : resolveCatalogPaths());
|
|
144
|
+
const staticPaths = options.paths ?? null;
|
|
133
145
|
const allowedOrigins = options.allowedOrigins ?? [];
|
|
134
146
|
return createServer((request, response) => {
|
|
135
147
|
void (async () => {
|
|
@@ -146,9 +158,24 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
146
158
|
json(response, 200, catenceRuntimeHealth());
|
|
147
159
|
return;
|
|
148
160
|
}
|
|
161
|
+
if (pathname === '/api/v1/athletes' && request.method === 'GET') {
|
|
162
|
+
if (!catalogPaths) {
|
|
163
|
+
json(response, 200, { defaultAthleteId: 'local', athletes: [{ id: 'local', label: 'Local athlete' }] });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const catalog = await loadCatalog(catalogPaths);
|
|
168
|
+
json(response, 200, { defaultAthleteId: catalog.defaultAthleteId, athletes: catalog.athletes });
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
json(response, 400, { error: { code: 'catalog_unavailable', message: error instanceof Error ? error.message : String(error) } });
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
149
175
|
if (pathname === '/api/v1/dashboard' && request.method === 'GET') {
|
|
150
176
|
try {
|
|
151
|
-
|
|
177
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
178
|
+
json(response, 200, await dashboardSnapshot(await dashboardPaths(catalogPaths, staticPaths, url), url));
|
|
152
179
|
}
|
|
153
180
|
catch (error) {
|
|
154
181
|
json(response, 400, { error: { code: 'dashboard_unavailable', message: error instanceof Error ? error.message : String(error) } });
|
|
@@ -156,7 +183,7 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
156
183
|
return;
|
|
157
184
|
}
|
|
158
185
|
if (pathname === '/mcp') {
|
|
159
|
-
await handleMcpRequest(request, response,
|
|
186
|
+
await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
|
|
160
187
|
return;
|
|
161
188
|
}
|
|
162
189
|
json(response, 404, { error: { code: 'not_found', message: 'No Catence endpoint matches this request.' } });
|