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.
Files changed (51) hide show
  1. package/README.md +239 -269
  2. package/config.example.json +32 -20
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +48 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/repository.d.ts +2 -0
  12. package/dist/core/query/repository.js +42 -0
  13. package/dist/core/runtime/catalog.d.ts +41 -0
  14. package/dist/core/runtime/catalog.js +121 -0
  15. package/dist/core/runtime/configuration.d.ts +1 -1
  16. package/dist/core/runtime/configuration.js +3 -3
  17. package/dist/core/runtime/secrets.d.ts +24 -0
  18. package/dist/core/runtime/secrets.js +68 -0
  19. package/dist/core/runtime/self-update.d.ts +44 -0
  20. package/dist/core/runtime/self-update.js +138 -0
  21. package/dist/elt/application/management.d.ts +2 -0
  22. package/dist/elt/application/management.js +197 -18
  23. package/dist/elt/ingestion/importer.d.ts +3 -2
  24. package/dist/elt/ingestion/importer.js +4 -3
  25. package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
  26. package/dist/elt/ingestion/providers/intervals.js +4 -1
  27. package/dist/elt/ingestion/providers/strava/service.js +9 -2
  28. package/dist/elt/storage/database.d.ts +7 -2
  29. package/dist/elt/storage/database.js +81 -4
  30. package/dist/elt/storage/migrations.js +25 -1
  31. package/dist/interfaces/cli/commands.js +201 -46
  32. package/dist/interfaces/http/cli-options.d.ts +2 -2
  33. package/dist/interfaces/http/cli-options.js +10 -10
  34. package/dist/interfaces/http/server.d.ts +4 -1
  35. package/dist/interfaces/http/server.js +31 -4
  36. package/dist/interfaces/mcp/cli-options.d.ts +2 -2
  37. package/dist/interfaces/mcp/cli-options.js +14 -14
  38. package/dist/interfaces/mcp/main.js +12 -5
  39. package/dist/interfaces/mcp/server.d.ts +2 -2
  40. package/dist/interfaces/mcp/server.js +156 -98
  41. package/dist/runtime/index.d.ts +8 -1
  42. package/dist/runtime/index.js +4 -1
  43. package/docs/deployment.md +338 -0
  44. package/docs/docker-deployment.md +232 -0
  45. package/docs/llm-providers.md +467 -0
  46. package/package.json +6 -2
  47. package/python/catence/providers/garmin/cli.py +30 -9
  48. package/python/catence/providers/garmin/progress.py +85 -0
  49. package/python/catence/providers/garmin/worker.py +132 -5
  50. package/scripts/discover-opencode-go.mjs +210 -0
  51. 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
- export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration];
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 { connectStrava, connectStravaWithCallback, createDemoStore, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, resolvePaths, retryDataSync, syncData, unlinkActivity } from '../../runtime/index.js';
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('Local fitness data extraction, targeted enrichment, and normalization.')
7
- .option('--data-dir <directory>', 'directory holding the local Catence database and artifacts', process.env.CATENCE_DATA_DIR ?? '.catence');
8
- function currentPaths() {
9
- return resolvePaths(program.opts().dataDir);
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 demoPaths() {
12
- const dataDirWasProvided = process.argv.some((argument) => argument === '--data-dir' || argument.startsWith('--data-dir='));
13
- const dataDir = dataDirWasProvided || process.env.CATENCE_DATA_DIR
14
- ? program.opts().dataDir
15
- : './catence-demo';
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
- program.command('init')
19
- .description('Create an empty local data store without contacting a provider.')
20
- .action(async () => {
21
- process.stdout.write(`${JSON.stringify(await initializeDataStore(currentPaths()), null, 2)}\n`);
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 local demo store without contacting providers. Refuses to overwrite a non-demo data directory.')
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 days = Number(options.days);
29
- const seed = Number(options.seed);
30
- process.stdout.write(`${JSON.stringify(await createDemoStore(demoPaths(), { days, seed }), null, 2)}\n`);
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
- process.stdout.write(`${JSON.stringify(await syncData(currentPaths(), options.provider, options.from, true, options.refresh ?? false), null, 2)}\n`);
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 (including Garmin activity details, files, and streams)')
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
- process.stdout.write(`${JSON.stringify(await syncData(currentPaths(), options.provider, options.from, false, options.refresh ?? false, true), null, 2)}\n`);
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
- process.stdout.write(`${JSON.stringify(await retryDataSync(currentPaths(), options.run), null, 2)}\n`);
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 without storing credentials in DuckDB.')
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(currentPaths(), redirectUri, (url) => {
70
- process.stderr.write(`Open this URL in your browser to connect Strava:\n${url}\nWaiting for the local callback…\n`);
71
- }, timeoutSeconds * 1_000)
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 locally stored credentials for a source account.')
142
+ program.command('disconnect').description('Remove one athlete’s stored provider connection.')
76
143
  .command('strava')
77
144
  .action(async () => {
78
- process.stdout.write(JSON.stringify(await disconnectStravaAccount(currentPaths()), null, 2) + "\n");
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
- .requiredOption('--source <activity-source-id>')
83
- .requiredOption('--activity <activity-id>')
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
- .requiredOption('--source <activity-source-id>')
89
- .action(async (options) => {
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
- process.stdout.write(`${JSON.stringify(await dataStatus(currentPaths()), null, 2)}\n`);
158
+ const { paths } = await currentAthlete();
159
+ process.stdout.write(`${JSON.stringify(await dataStatus(paths), null, 2)}\n`);
94
160
  });
95
- program.command('build-retrieval-index')
96
- .description('Build the derived local text-retrieval index after a completed sync.')
97
- .action(async () => {
98
- process.stdout.write(`${JSON.stringify(await rebuildRetrievalIndex(currentPaths()), null, 2)}\n`);
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
- dataDir?: string;
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 --data-dir <directory> Catence data directory. Overrides CATENCE_DATA_DIR.\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.";
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
- --data-dir <directory> Catence data directory. Overrides CATENCE_DATA_DIR.
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 dataDir;
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 { dataDir, host, port, allowedOrigins, help: true };
34
- if (argument === '--data-dir') {
35
- dataDir = requiredValue(arguments_, index, '--data-dir');
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('--data-dir=')) {
40
- dataDir = argument.slice('--data-dir='.length);
41
- if (!dataDir)
42
- throw new Error('--data-dir requires a value.');
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 { dataDir, host, port, allowedOrigins, help: false };
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, resolvePaths } from '../../runtime/index.js';
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 paths = options.paths ?? resolvePaths();
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
- json(response, 200, await dashboardSnapshot(paths, new URL(request.url ?? '/', 'http://localhost')));
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, paths);
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.' } });