catence 0.1.0 → 0.2.0-beta.2

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.
@@ -0,0 +1,111 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { z } from 'zod';
6
+ import { resolvePaths } from './configuration.js';
7
+ const CATALOG_FORMAT_VERSION = 1;
8
+ const athleteIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,62}$/, 'must use lowercase letters, digits, and hyphens, starting with a letter');
9
+ const catalogSchema = z.object({
10
+ formatVersion: z.literal(CATALOG_FORMAT_VERSION),
11
+ defaultAthleteId: athleteIdSchema,
12
+ athletes: z.array(z.object({
13
+ id: athleteIdSchema,
14
+ label: z.string().trim().min(1).max(120),
15
+ createdAt: z.string().datetime(),
16
+ }).strict()).min(1),
17
+ }).strict().superRefine((catalog, context) => {
18
+ const ids = catalog.athletes.map((athlete) => athlete.id);
19
+ if (new Set(ids).size !== ids.length)
20
+ context.addIssue({ code: 'custom', path: ['athletes'], message: 'athlete IDs must be unique' });
21
+ if (!ids.includes(catalog.defaultAthleteId))
22
+ context.addIssue({ code: 'custom', path: ['defaultAthleteId'], message: 'must name a configured athlete' });
23
+ });
24
+ export function defaultCatalogHome() {
25
+ return path.resolve(process.env.CATENCE_HOME ?? path.join(homedir(), '.catence'));
26
+ }
27
+ export function resolveCatalogPaths(root = defaultCatalogHome()) {
28
+ const absoluteRoot = path.resolve(root);
29
+ return {
30
+ root: absoluteRoot,
31
+ catalog: path.join(absoluteRoot, 'catalog.json'),
32
+ athletes: path.join(absoluteRoot, 'athletes'),
33
+ console: path.join(absoluteRoot, 'console'),
34
+ };
35
+ }
36
+ export function athleteStorePaths(catalogPaths, athleteId) {
37
+ const id = athleteIdSchema.parse(athleteId);
38
+ return resolvePaths(path.join(catalogPaths.athletes, id));
39
+ }
40
+ function legacyStoreMessage(paths) {
41
+ return `Catence 0.2 uses a catalog at ${paths.root}. The existing directory looks like a 0.1 data store and cannot be migrated automatically. Create a fresh catalog and re-sync each athlete.`;
42
+ }
43
+ async function hasEntries(directory) {
44
+ return (await readdir(directory).catch((error) => error.code === 'ENOENT' ? [] : Promise.reject(error))).length > 0;
45
+ }
46
+ export async function loadCatalog(paths = resolveCatalogPaths()) {
47
+ const contents = await readFile(paths.catalog, 'utf8').catch((error) => {
48
+ if (error.code !== 'ENOENT')
49
+ throw error;
50
+ if (existsSync(path.join(paths.root, 'catence.duckdb')))
51
+ throw new Error(legacyStoreMessage(paths));
52
+ throw new Error(`No Catence catalog exists at ${paths.root}. Run catence-data setup first.`);
53
+ });
54
+ try {
55
+ return catalogSchema.parse(JSON.parse(contents));
56
+ }
57
+ catch (error) {
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ throw new Error(`Invalid Catence catalog at ${paths.catalog}: ${message}`);
60
+ }
61
+ }
62
+ async function writeCatalog(paths, catalog) {
63
+ await mkdir(paths.root, { recursive: true, mode: 0o700 });
64
+ const temporary = `${paths.catalog}.tmp`;
65
+ await writeFile(temporary, `${JSON.stringify(catalog, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
66
+ await rename(temporary, paths.catalog);
67
+ }
68
+ export async function initializeCatalog(paths = resolveCatalogPaths(), athlete) {
69
+ if (existsSync(paths.catalog))
70
+ return loadCatalog(paths);
71
+ if (await hasEntries(paths.root)) {
72
+ if (existsSync(path.join(paths.root, 'catence.duckdb')))
73
+ throw new Error(legacyStoreMessage(paths));
74
+ throw new Error(`Refusing to initialize ${paths.root}: choose an empty --home directory or remove its unrelated contents.`);
75
+ }
76
+ const id = athleteIdSchema.parse(athlete.id);
77
+ const record = { id, label: athlete.label.trim(), createdAt: new Date().toISOString() };
78
+ if (!record.label)
79
+ throw new Error('Athlete label is required.');
80
+ const catalog = { formatVersion: CATALOG_FORMAT_VERSION, defaultAthleteId: id, athletes: [record] };
81
+ await writeCatalog(paths, catalog);
82
+ return catalog;
83
+ }
84
+ export async function addAthlete(paths, athlete) {
85
+ const catalog = await loadCatalog(paths);
86
+ const id = athleteIdSchema.parse(athlete.id);
87
+ if (catalog.athletes.some((entry) => entry.id === id))
88
+ throw new Error(`Athlete ${id} already exists.`);
89
+ const label = athlete.label.trim();
90
+ if (!label)
91
+ throw new Error('Athlete label is required.');
92
+ const next = {
93
+ ...catalog,
94
+ defaultAthleteId: athlete.setDefault ? id : catalog.defaultAthleteId,
95
+ athletes: [...catalog.athletes, { id, label, createdAt: new Date().toISOString() }],
96
+ };
97
+ await writeCatalog(paths, next);
98
+ return next;
99
+ }
100
+ export async function resolveAthlete(paths, athleteId) {
101
+ const catalog = await loadCatalog(paths);
102
+ const athlete = catalog.athletes.find((entry) => entry.id === athleteId);
103
+ if (!athlete)
104
+ throw new Error(`Unknown athlete ${athleteId}. Call list_athletes to inspect the configured catalog.`);
105
+ return { athlete, paths: athleteStorePaths(paths, athlete.id) };
106
+ }
107
+ export async function defaultAthlete(paths = resolveCatalogPaths()) {
108
+ const catalog = await loadCatalog(paths);
109
+ return resolveAthlete(paths, catalog.defaultAthleteId);
110
+ }
111
+ //# sourceMappingURL=catalog.js.map
@@ -4,7 +4,7 @@ export type { CatencePaths } from '../../contracts/runtime.js';
4
4
  export declare function resolvePaths(root?: string): CatencePaths;
5
5
  export declare function ensurePaths(paths: CatencePaths): Promise<void>;
6
6
  export declare function defaultFromDate(now?: Date): string;
7
- export declare function requireIntervalsConfig(): {
7
+ export declare function requireIntervalsConfig(environment?: NodeJS.ProcessEnv): {
8
8
  apiKey: string;
9
9
  athleteId: string;
10
10
  };
@@ -23,11 +23,11 @@ export function defaultFromDate(now = new Date()) {
23
23
  const date = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), now.getUTCDate()));
24
24
  return date.toISOString().slice(0, 10);
25
25
  }
26
- export function requireIntervalsConfig() {
27
- const apiKey = process.env.INTERVALS_API_KEY;
26
+ export function requireIntervalsConfig(environment = process.env) {
27
+ const apiKey = environment.INTERVALS_API_KEY;
28
28
  if (!apiKey)
29
29
  throw new Error('INTERVALS_API_KEY is required for an Intervals sync.');
30
- return { apiKey, athleteId: process.env.INTERVALS_ATHLETE_ID ?? '0' };
30
+ return { apiKey, athleteId: environment.INTERVALS_ATHLETE_ID ?? '0' };
31
31
  }
32
32
  const rateLimitSchema = z.object({ requests: z.number().int().positive(), windowSeconds: z.number().int().positive() }).strict();
33
33
  const nullableRateLimitSchema = rateLimitSchema.nullable();
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import type { CatencePaths } from '../../contracts/runtime.js';
3
+ declare const secretSchema: z.ZodObject<{
4
+ garmin: z.ZodOptional<z.ZodObject<{
5
+ email: z.ZodOptional<z.ZodString>;
6
+ password: z.ZodOptional<z.ZodString>;
7
+ }, z.core.$strict>>;
8
+ intervals: z.ZodOptional<z.ZodObject<{
9
+ apiKey: z.ZodOptional<z.ZodString>;
10
+ athleteId: z.ZodOptional<z.ZodString>;
11
+ }, z.core.$strict>>;
12
+ strava: z.ZodOptional<z.ZodObject<{
13
+ clientId: z.ZodOptional<z.ZodString>;
14
+ clientSecret: z.ZodOptional<z.ZodString>;
15
+ }, z.core.$strict>>;
16
+ }, z.core.$strict>;
17
+ export type AthleteSecrets = z.infer<typeof secretSchema>;
18
+ export type SecretProvider = keyof AthleteSecrets;
19
+ export declare function providerSecretPath(paths: CatencePaths): string;
20
+ export declare function readAthleteSecrets(paths: CatencePaths): Promise<AthleteSecrets>;
21
+ export declare function setAthleteSecret(paths: CatencePaths, provider: SecretProvider, field: string, value: string): Promise<void>;
22
+ /** Build only the provider environment needed by a selected athlete worker. */
23
+ export declare function athleteProviderEnvironment(paths: CatencePaths, base?: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv>;
24
+ export {};
@@ -0,0 +1,68 @@
1
+ import { chmod } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { ensurePaths } from './configuration.js';
5
+ const secretSchema = z.object({
6
+ garmin: z.object({ email: z.string().min(1).optional(), password: z.string().min(1).optional() }).strict().optional(),
7
+ intervals: z.object({ apiKey: z.string().min(1).optional(), athleteId: z.string().min(1).optional() }).strict().optional(),
8
+ strava: z.object({ clientId: z.string().min(1).optional(), clientSecret: z.string().min(1).optional() }).strict().optional(),
9
+ }).strict();
10
+ const secretFields = {
11
+ garmin: ['email', 'password'],
12
+ intervals: ['apiKey', 'athleteId'],
13
+ strava: ['clientId', 'clientSecret'],
14
+ };
15
+ export function providerSecretPath(paths) {
16
+ return path.join(paths.secrets, 'providers.json');
17
+ }
18
+ export async function readAthleteSecrets(paths) {
19
+ const { readFile } = await import('node:fs/promises');
20
+ const contents = await readFile(providerSecretPath(paths), 'utf8').catch((error) => error.code === 'ENOENT' ? '{}' : Promise.reject(error));
21
+ try {
22
+ return secretSchema.parse(JSON.parse(contents));
23
+ }
24
+ catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ throw new Error(`Invalid provider secrets at ${providerSecretPath(paths)}: ${message}`);
27
+ }
28
+ }
29
+ export async function setAthleteSecret(paths, provider, field, value) {
30
+ if (!secretFields[provider].includes(field))
31
+ throw new Error(`${field} is not a supported ${provider} secret field.`);
32
+ if (!value.trim())
33
+ throw new Error('Secret values cannot be empty.');
34
+ await ensurePaths(paths);
35
+ const secrets = await readAthleteSecrets(paths);
36
+ const next = { ...secrets, [provider]: { ...(secrets[provider] ?? {}), [field]: value } };
37
+ const { writeFile, rename } = await import('node:fs/promises');
38
+ const destination = providerSecretPath(paths);
39
+ const temporary = `${destination}.tmp`;
40
+ await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
41
+ await chmod(temporary, 0o600);
42
+ await rename(temporary, destination);
43
+ await chmod(destination, 0o600);
44
+ }
45
+ /** Build only the provider environment needed by a selected athlete worker. */
46
+ export async function athleteProviderEnvironment(paths, base = process.env) {
47
+ const secrets = await readAthleteSecrets(paths);
48
+ const environment = { ...base };
49
+ // Never let credentials inherited by the shared process leak into an
50
+ // athlete store that has not explicitly configured that provider.
51
+ for (const name of ['GARMIN_EMAIL', 'GARMIN_PASSWORD', 'INTERVALS_API_KEY', 'INTERVALS_ATHLETE_ID', 'STRAVA_CLIENT_ID', 'STRAVA_CLIENT_SECRET']) {
52
+ delete environment[name];
53
+ }
54
+ if (secrets.garmin?.email)
55
+ environment.GARMIN_EMAIL = secrets.garmin.email;
56
+ if (secrets.garmin?.password)
57
+ environment.GARMIN_PASSWORD = secrets.garmin.password;
58
+ if (secrets.intervals?.apiKey)
59
+ environment.INTERVALS_API_KEY = secrets.intervals.apiKey;
60
+ if (secrets.intervals?.athleteId)
61
+ environment.INTERVALS_ATHLETE_ID = secrets.intervals.athleteId;
62
+ if (secrets.strava?.clientId)
63
+ environment.STRAVA_CLIENT_ID = secrets.strava.clientId;
64
+ if (secrets.strava?.clientSecret)
65
+ environment.STRAVA_CLIENT_SECRET = secrets.strava.clientSecret;
66
+ return environment;
67
+ }
68
+ //# sourceMappingURL=secrets.js.map
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { buildRetrievalIndex } from '../../core/retrieval/index.js';
10
10
  import { defaultFromDate, ensurePaths, requireIntervalsConfig } from '../../core/runtime/configuration.js';
11
+ import { athleteProviderEnvironment } from '../../core/runtime/secrets.js';
11
12
  import { importJsonl } from '../ingestion/importer.js';
12
13
  import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
13
14
  import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
@@ -45,7 +46,8 @@ async function syncProvider(database, paths, provider, options) {
45
46
  let extractionStarted = false;
46
47
  try {
47
48
  if (provider === 'intervals') {
48
- const config = requireIntervalsConfig();
49
+ const environment = await athleteProviderEnvironment(paths);
50
+ const config = requireIntervalsConfig(environment);
49
51
  extractionStarted = true;
50
52
  await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
51
53
  }
@@ -71,7 +73,7 @@ async function syncProvider(database, paths, provider, options) {
71
73
  arguments_.push('--historical-only');
72
74
  await execFileAsync('uv', arguments_, {
73
75
  cwd: packageRoot(),
74
- env: { ...process.env, UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
76
+ env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
75
77
  });
76
78
  if (!existsSync(output))
77
79
  throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { ensurePaths, loadCatenceConfig } from '../../../../core/runtime/configuration.js';
8
+ import { athleteProviderEnvironment } from '../../../../core/runtime/secrets.js';
8
9
  import { CatenceDatabase, openReadOnlyRepository } from '../../../storage/database.js';
9
10
  import { importJsonl } from '../../importer.js';
10
11
  import { withDataWriteLock } from '../../../storage/write-lock.js';
@@ -50,7 +51,7 @@ async function invokeWorker(paths, args, resultPath) {
50
51
  try {
51
52
  await execFileAsync('uv', ['run', 'python', '-m', 'python.catence.providers.strava.cli', ...args, '--data-dir', paths.root, '--result', resultPath], {
52
53
  cwd: packageRoot(),
53
- env: { ...process.env, UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
54
+ env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
54
55
  });
55
56
  }
56
57
  catch (error) {
@@ -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, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, initializeCatalog, linkActivity, loadCatalog, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, setAthleteSecret, syncData, 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,35 @@ 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
+ program.command('build-retrieval-index').description('Build derived local retrieval context for one athlete.').action(async () => {
162
+ const { paths } = await currentAthlete();
163
+ process.stdout.write(`${JSON.stringify(await rebuildRetrievalIndex(paths), null, 2)}\n`);
99
164
  });
100
165
  await program.parseAsync();
101
166
  //# 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.' } });