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.
- package/README.md +121 -293
- package/config.example.json +7 -20
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/runtime/catalog.d.ts +41 -0
- package/dist/core/runtime/catalog.js +111 -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/elt/application/management.js +4 -2
- package/dist/elt/ingestion/providers/strava/service.js +2 -1
- package/dist/interfaces/cli/commands.js +111 -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 +149 -98
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +2 -0
- package/docs/llm-providers.md +237 -0
- package/package.json +2 -2
|
@@ -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 =
|
|
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:
|
|
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
|
|
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: { ...
|
|
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: { ...
|
|
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 {
|
|
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('
|
|
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,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(
|
|
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
|
-
program.command('build-retrieval-index')
|
|
96
|
-
|
|
97
|
-
.
|
|
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
|
-
|
|
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.' } });
|