catence 0.1.0 → 0.2.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -269
- package/config.example.json +32 -20
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +48 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +42 -0
- package/dist/core/runtime/catalog.d.ts +41 -0
- package/dist/core/runtime/catalog.js +121 -0
- package/dist/core/runtime/configuration.d.ts +1 -1
- package/dist/core/runtime/configuration.js +3 -3
- package/dist/core/runtime/secrets.d.ts +24 -0
- package/dist/core/runtime/secrets.js +68 -0
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/management.d.ts +2 -0
- package/dist/elt/application/management.js +197 -18
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +4 -3
- package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
- package/dist/elt/ingestion/providers/intervals.js +4 -1
- package/dist/elt/ingestion/providers/strava/service.js +9 -2
- package/dist/elt/storage/database.d.ts +7 -2
- package/dist/elt/storage/database.js +81 -4
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/interfaces/cli/commands.js +201 -46
- package/dist/interfaces/http/cli-options.d.ts +2 -2
- package/dist/interfaces/http/cli-options.js +10 -10
- package/dist/interfaces/http/server.d.ts +4 -1
- package/dist/interfaces/http/server.js +31 -4
- package/dist/interfaces/mcp/cli-options.d.ts +2 -2
- package/dist/interfaces/mcp/cli-options.js +14 -14
- package/dist/interfaces/mcp/main.js +12 -5
- package/dist/interfaces/mcp/server.d.ts +2 -2
- package/dist/interfaces/mcp/server.js +156 -98
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +4 -1
- package/docs/deployment.md +338 -0
- package/docs/docker-deployment.md +232 -0
- package/docs/llm-providers.md +467 -0
- package/package.json +6 -2
- package/python/catence/providers/garmin/cli.py +30 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/worker.py +132 -5
- package/scripts/discover-opencode-go.mjs +210 -0
- package/scripts/discover-opencode-zen.mjs +218 -0
package/config.example.json
CHANGED
|
@@ -23,40 +23,52 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"console": {
|
|
26
|
-
"defaultProfile": "
|
|
26
|
+
"defaultProfile": "openai",
|
|
27
27
|
"limits": {
|
|
28
28
|
"toolRounds": 8,
|
|
29
29
|
"toolResultCharacters": 24000
|
|
30
30
|
},
|
|
31
31
|
"profiles": {
|
|
32
|
-
"azure-foundry": {
|
|
33
|
-
"label": "Azure Foundry",
|
|
34
|
-
"defaultModel": "terra",
|
|
35
|
-
"defaultReasoningEffort": "medium",
|
|
36
|
-
"models": {
|
|
37
|
-
"terra": { "label": "GPT-5.6 Terra", "model": "azure_ai/gpt-5.6-terra" },
|
|
38
|
-
"luna": { "label": "GPT-5.6 Luna", "model": "azure_ai/gpt-5.6-luna" },
|
|
39
|
-
"sol": { "label": "GPT-5.6 Sol", "model": "azure_ai/gpt-5.6-sol" }
|
|
40
|
-
},
|
|
41
|
-
"apiKeyEnv": "AZURE_API_KEY",
|
|
42
|
-
"apiBaseEnv": "AZURE_API_BASE",
|
|
43
|
-
"apiVersionEnv": "AZURE_API_VERSION"
|
|
44
|
-
},
|
|
45
|
-
"opencode": {
|
|
46
|
-
"label": "OpenCode-compatible",
|
|
47
|
-
"model": "openai/your-model-name",
|
|
48
|
-
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
49
|
-
"apiBaseEnv": "OPENCODE_API_BASE"
|
|
50
|
-
},
|
|
51
32
|
"openai": {
|
|
52
33
|
"label": "OpenAI",
|
|
53
34
|
"model": "openai/gpt-5-mini",
|
|
54
35
|
"apiKeyEnv": "OPENAI_API_KEY"
|
|
55
36
|
},
|
|
37
|
+
"openai-compatible": {
|
|
38
|
+
"label": "OpenAI-compatible",
|
|
39
|
+
"model": "openai/your-model-name",
|
|
40
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
41
|
+
"apiBaseEnv": "OPENAI_API_BASE"
|
|
42
|
+
},
|
|
56
43
|
"anthropic": {
|
|
57
44
|
"label": "Anthropic",
|
|
58
45
|
"model": "anthropic/claude-sonnet-4-5",
|
|
59
46
|
"apiKeyEnv": "ANTHROPIC_API_KEY"
|
|
47
|
+
},
|
|
48
|
+
"opencode-zen": {
|
|
49
|
+
"label": "OpenCode Zen",
|
|
50
|
+
"defaultModel": "deepseek-v4-flash-free",
|
|
51
|
+
"models": {
|
|
52
|
+
"deepseek-v4-flash-free": {
|
|
53
|
+
"label": "DeepSeek V4 Flash Free",
|
|
54
|
+
"model": "openai/deepseek-v4-flash-free",
|
|
55
|
+
"variants": { "High": "high", "Max": "max" }
|
|
56
|
+
},
|
|
57
|
+
"nemotron-3-ultra-free": { "label": "Nemotron 3 Ultra Free", "model": "openai/nemotron-3-ultra-free" },
|
|
58
|
+
"big-pickle": { "label": "Big Pickle", "model": "openai/big-pickle" }
|
|
59
|
+
},
|
|
60
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
61
|
+
"apiBaseEnv": "OPENCODE_ZEN_API_BASE"
|
|
62
|
+
},
|
|
63
|
+
"opencode-zen-responses": {
|
|
64
|
+
"label": "OpenCode Zen (Responses)",
|
|
65
|
+
"defaultModel": "gpt-5.6-luna",
|
|
66
|
+
"models": {
|
|
67
|
+
"gpt-5.6-luna": { "label": "GPT 5.6 Luna", "model": "openai/responses/gpt-5.6-luna" },
|
|
68
|
+
"gpt-5.6-terra": { "label": "GPT 5.6 Terra", "model": "openai/responses/gpt-5.6-terra" }
|
|
69
|
+
},
|
|
70
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
71
|
+
"apiBaseEnv": "OPENCODE_ZEN_RESPONSES_API_BASE"
|
|
60
72
|
}
|
|
61
73
|
}
|
|
62
74
|
}
|
package/dist/contracts/index.js
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const STALE_RUN_TIMEOUT_MS: number;
|
|
2
|
+
export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "importing", "completed", "interrupted", "failed", "timed_out"];
|
|
3
|
+
export type SyncStage = (typeof SYNC_STAGES)[number];
|
|
4
|
+
export interface SyncProgressState {
|
|
5
|
+
runId: string;
|
|
6
|
+
provider: string;
|
|
7
|
+
stage: SyncStage;
|
|
8
|
+
currentStep: string | null;
|
|
9
|
+
completedUnits: number;
|
|
10
|
+
totalUnits: number | null;
|
|
11
|
+
percentComplete: number;
|
|
12
|
+
elapsedSeconds: number;
|
|
13
|
+
estimatedRemainingSeconds: number | null;
|
|
14
|
+
heartbeatAt: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SyncProgressSnapshot {
|
|
17
|
+
running: SyncProgressState[];
|
|
18
|
+
recent: SyncProgressState[];
|
|
19
|
+
}
|
|
20
|
+
export interface InterruptedRuns {
|
|
21
|
+
runIds: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare function normalizeProgress(value: unknown): SyncProgressState | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const STALE_RUN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
2
|
+
export const SYNC_STAGES = [
|
|
3
|
+
'starting',
|
|
4
|
+
'login',
|
|
5
|
+
'singletons',
|
|
6
|
+
'daily',
|
|
7
|
+
'range',
|
|
8
|
+
'ftp_history',
|
|
9
|
+
'max_metrics',
|
|
10
|
+
'hrv_history',
|
|
11
|
+
'scores',
|
|
12
|
+
'activities',
|
|
13
|
+
'collections',
|
|
14
|
+
'importing',
|
|
15
|
+
'completed',
|
|
16
|
+
'interrupted',
|
|
17
|
+
'failed',
|
|
18
|
+
'timed_out',
|
|
19
|
+
];
|
|
20
|
+
export function normalizeProgress(value) {
|
|
21
|
+
if (typeof value !== 'object' || value === null) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const record = value;
|
|
25
|
+
const runId = typeof record.runId === 'string' ? record.runId : '';
|
|
26
|
+
const provider = typeof record.provider === 'string' ? record.provider : '';
|
|
27
|
+
if (!runId || !provider) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const stage = SYNC_STAGES.includes(record.stage)
|
|
31
|
+
? record.stage
|
|
32
|
+
: 'starting';
|
|
33
|
+
const numberOr = (value, fallback) => typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
34
|
+
const roundOrNull = (value) => typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.round(value)) : null;
|
|
35
|
+
return {
|
|
36
|
+
runId,
|
|
37
|
+
provider,
|
|
38
|
+
stage,
|
|
39
|
+
currentStep: typeof record.currentStep === 'string' ? record.currentStep : null,
|
|
40
|
+
completedUnits: Math.max(0, Math.round(numberOr(record.completedUnits, 0))),
|
|
41
|
+
totalUnits: roundOrNull(record.totalUnits),
|
|
42
|
+
percentComplete: Math.min(100, Math.max(0, numberOr(record.percentComplete, 0))),
|
|
43
|
+
elapsedSeconds: Math.max(0, numberOr(record.elapsedSeconds, 0)),
|
|
44
|
+
estimatedRemainingSeconds: roundOrNull(record.estimatedRemainingSeconds),
|
|
45
|
+
heartbeatAt: typeof record.heartbeatAt === 'string' ? record.heartbeatAt : new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=progress.js.map
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.10";
|
|
7
7
|
export declare const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export type CatenceRuntimeCapabilities = {
|
|
9
9
|
mcp: true;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export const CATENCE_RUNTIME_VERSION = '0.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.10';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CatencePaths } from '../contracts/runtime.js';
|
|
2
|
+
export type SyncLogger = {
|
|
3
|
+
info: (message: string, context?: Record<string, unknown>) => void;
|
|
4
|
+
warn: (message: string, context?: Record<string, unknown>) => void;
|
|
5
|
+
error: (message: string, context?: Record<string, unknown>) => void;
|
|
6
|
+
debug: (message: string, context?: Record<string, unknown>) => void;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the sync log file path.
|
|
10
|
+
*
|
|
11
|
+
* `CATENCE_LOG` may be an absolute path or a path relative to the catalog home.
|
|
12
|
+
* When unset (the default), logs go to stderr only so the console stays clean
|
|
13
|
+
* and stdout remains machine-readable for callers like sync.sh.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveLogFile(paths: CatencePaths, environment?: NodeJS.ProcessEnv): string | null;
|
|
16
|
+
export declare function createSyncLogger(paths: CatencePaths, environment?: NodeJS.ProcessEnv): SyncLogger;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const LEVEL_WEIGHT = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
4
|
+
function configuredLevel(environment = process.env) {
|
|
5
|
+
const raw = (environment.CATENCE_LOG_LEVEL ?? 'info').toLowerCase();
|
|
6
|
+
if (raw === 'debug')
|
|
7
|
+
return LEVEL_WEIGHT.debug;
|
|
8
|
+
if (raw === 'warn')
|
|
9
|
+
return LEVEL_WEIGHT.warn;
|
|
10
|
+
if (raw === 'error')
|
|
11
|
+
return LEVEL_WEIGHT.error;
|
|
12
|
+
return LEVEL_WEIGHT.info;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the sync log file path.
|
|
16
|
+
*
|
|
17
|
+
* `CATENCE_LOG` may be an absolute path or a path relative to the catalog home.
|
|
18
|
+
* When unset (the default), logs go to stderr only so the console stays clean
|
|
19
|
+
* and stdout remains machine-readable for callers like sync.sh.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveLogFile(paths, environment = process.env) {
|
|
22
|
+
const configured = environment.CATENCE_LOG;
|
|
23
|
+
if (!configured)
|
|
24
|
+
return null;
|
|
25
|
+
return path.isAbsolute(configured) ? configured : path.join(paths.root, configured);
|
|
26
|
+
}
|
|
27
|
+
export function createSyncLogger(paths, environment = process.env) {
|
|
28
|
+
const threshold = configuredLevel(environment);
|
|
29
|
+
const logFile = resolveLogFile(paths, environment);
|
|
30
|
+
const write = (level, message, context) => {
|
|
31
|
+
if (LEVEL_WEIGHT[level] < threshold)
|
|
32
|
+
return;
|
|
33
|
+
const line = formatLogLine(level, message, context);
|
|
34
|
+
process.stderr.write(`${line}\n`);
|
|
35
|
+
if (logFile) {
|
|
36
|
+
// Fire-and-forget: never block a sync run on log persistence.
|
|
37
|
+
appendLogLine(logFile, `${line}\n`).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
info: (message, context) => write('info', message, context),
|
|
42
|
+
warn: (message, context) => write('warn', message, context),
|
|
43
|
+
error: (message, context) => write('error', message, context),
|
|
44
|
+
debug: (message, context) => write('debug', message, context),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function formatLogLine(level, message, context) {
|
|
48
|
+
const timestamp = new Date().toISOString();
|
|
49
|
+
const tag = level.toUpperCase().padEnd(5);
|
|
50
|
+
let line = `[${timestamp}] [${tag}] ${message}`;
|
|
51
|
+
if (context && Object.keys(context).length > 0) {
|
|
52
|
+
line += ` ${serializeContext(context)}`;
|
|
53
|
+
}
|
|
54
|
+
return line;
|
|
55
|
+
}
|
|
56
|
+
function serializeContext(context) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.stringify(context);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let appendLogLine = async (file, line) => {
|
|
65
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
66
|
+
await appendFile(file, line, 'utf8');
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=logging.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
2
|
+
import { type SyncProgressSnapshot } from '../../contracts/progress.js';
|
|
2
3
|
import type { QueryValues, ReadDataStore } from '../../contracts/storage.js';
|
|
3
4
|
import { type DatasetDefinition } from './catalog.js';
|
|
4
5
|
export declare class ReadOnlyRepository {
|
|
@@ -10,6 +11,7 @@ export declare class ReadOnlyRepository {
|
|
|
10
11
|
datasetRelation(datasetName: string): Promise<string>;
|
|
11
12
|
activitySampleRelation(): Promise<string>;
|
|
12
13
|
status(): Promise<Record<string, unknown>>;
|
|
14
|
+
progress(): Promise<SyncProgressSnapshot>;
|
|
13
15
|
coverage(): Promise<Record<string, unknown>>;
|
|
14
16
|
activity(activityId: string): Promise<Record<string, unknown> | null>;
|
|
15
17
|
summary(startDate: string, endDate: string): Promise<Record<string, unknown>>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { SYNC_STAGES } from '../../contracts/progress.js';
|
|
3
4
|
import { sqlString } from './sql.js';
|
|
4
5
|
import { DATASET_CATALOG, getDataset } from './catalog.js';
|
|
5
6
|
export class ReadOnlyRepository {
|
|
@@ -137,6 +138,33 @@ export class ReadOnlyRepository {
|
|
|
137
138
|
incrementalCursors: cursors,
|
|
138
139
|
};
|
|
139
140
|
}
|
|
141
|
+
async progress() {
|
|
142
|
+
// Same sequential-read discipline as status(): the shared native
|
|
143
|
+
// connection must not run these small reads concurrently.
|
|
144
|
+
const running = await this.rows(`
|
|
145
|
+
SELECT runs.run_id, runs.provider, coalesce(progress.stage, 'starting') AS stage,
|
|
146
|
+
progress.current_step,
|
|
147
|
+
coalesce(progress.completed_units, 0) AS completed_units,
|
|
148
|
+
progress.total_units,
|
|
149
|
+
coalesce(progress.percent, 0) AS percent,
|
|
150
|
+
coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
|
|
151
|
+
progress.estimated_remaining_seconds,
|
|
152
|
+
cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
|
|
153
|
+
FROM sync_runs AS runs
|
|
154
|
+
LEFT JOIN sync_run_progress AS progress USING (run_id)
|
|
155
|
+
WHERE runs.status = 'running'
|
|
156
|
+
ORDER BY runs.started_at DESC
|
|
157
|
+
`);
|
|
158
|
+
const recent = await this.rows(`
|
|
159
|
+
SELECT run_id, provider, stage, current_step, completed_units, total_units, percent,
|
|
160
|
+
elapsed_seconds, estimated_remaining_seconds,
|
|
161
|
+
cast(heartbeat_at AS VARCHAR) AS heartbeat_at
|
|
162
|
+
FROM sync_run_progress
|
|
163
|
+
ORDER BY heartbeat_at DESC
|
|
164
|
+
LIMIT 10
|
|
165
|
+
`);
|
|
166
|
+
return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
|
|
167
|
+
}
|
|
140
168
|
async coverage() {
|
|
141
169
|
const rows = await this.rows(`
|
|
142
170
|
SELECT 'activities' AS dataset, min(cast(started_at_utc AS DATE)) AS start_date, max(cast(started_at_utc AS DATE)) AS end_date, count(*)::INTEGER AS row_count FROM activities
|
|
@@ -166,6 +194,20 @@ export class ReadOnlyRepository {
|
|
|
166
194
|
return Object.values(DATASET_CATALOG);
|
|
167
195
|
}
|
|
168
196
|
}
|
|
197
|
+
function toSyncProgressState(row) {
|
|
198
|
+
return {
|
|
199
|
+
runId: row.run_id,
|
|
200
|
+
provider: row.provider,
|
|
201
|
+
stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
|
|
202
|
+
currentStep: row.current_step,
|
|
203
|
+
completedUnits: Math.max(0, Number(row.completed_units) || 0),
|
|
204
|
+
totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
|
|
205
|
+
percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
|
|
206
|
+
elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
|
|
207
|
+
estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
|
|
208
|
+
heartbeatAt: row.heartbeat_at,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
169
211
|
/** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
|
|
170
212
|
export function jsonSafe(value) {
|
|
171
213
|
if (typeof value === 'bigint')
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
3
|
+
declare const catalogSchema: z.ZodObject<{
|
|
4
|
+
formatVersion: z.ZodLiteral<1>;
|
|
5
|
+
defaultAthleteId: z.ZodString;
|
|
6
|
+
athletes: z.ZodArray<z.ZodObject<{
|
|
7
|
+
id: z.ZodString;
|
|
8
|
+
label: z.ZodString;
|
|
9
|
+
createdAt: z.ZodString;
|
|
10
|
+
}, z.core.$strict>>;
|
|
11
|
+
}, z.core.$strict>;
|
|
12
|
+
export type Athlete = z.infer<typeof catalogSchema>['athletes'][number];
|
|
13
|
+
export type CatenceCatalog = z.infer<typeof catalogSchema>;
|
|
14
|
+
export type CatalogPaths = {
|
|
15
|
+
root: string;
|
|
16
|
+
catalog: string;
|
|
17
|
+
athletes: string;
|
|
18
|
+
console: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function defaultCatalogHome(): string;
|
|
21
|
+
export declare function resolveCatalogPaths(root?: string): CatalogPaths;
|
|
22
|
+
export declare function athleteStorePaths(catalogPaths: CatalogPaths, athleteId: string): CatencePaths;
|
|
23
|
+
export declare function loadCatalog(paths?: CatalogPaths): Promise<CatenceCatalog>;
|
|
24
|
+
export declare function initializeCatalog(paths: CatalogPaths | undefined, athlete: {
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
}): Promise<CatenceCatalog>;
|
|
28
|
+
export declare function addAthlete(paths: CatalogPaths, athlete: {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
setDefault?: boolean;
|
|
32
|
+
}): Promise<CatenceCatalog>;
|
|
33
|
+
export declare function resolveAthlete(paths: CatalogPaths, athleteId: string): Promise<{
|
|
34
|
+
athlete: Athlete;
|
|
35
|
+
paths: CatencePaths;
|
|
36
|
+
}>;
|
|
37
|
+
export declare function defaultAthlete(paths?: CatalogPaths): Promise<{
|
|
38
|
+
athlete: Athlete;
|
|
39
|
+
paths: CatencePaths;
|
|
40
|
+
}>;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
/** Home-directory entries the Console creates on its own and must not block catalog initialization. */
|
|
44
|
+
const CONSOLE_ARTIFACTS = new Set(['config.json', 'console', '.files', '.chainlit', 'public']);
|
|
45
|
+
export async function loadCatalog(paths = resolveCatalogPaths()) {
|
|
46
|
+
const contents = await readFile(paths.catalog, 'utf8').catch((error) => {
|
|
47
|
+
if (error.code !== 'ENOENT')
|
|
48
|
+
throw error;
|
|
49
|
+
if (existsSync(path.join(paths.root, 'catence.duckdb')))
|
|
50
|
+
throw new Error(legacyStoreMessage(paths));
|
|
51
|
+
throw new Error(`No Catence catalog exists at ${paths.root}. Run catence-data setup first.`);
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
return catalogSchema.parse(JSON.parse(contents));
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
throw new Error(`Invalid Catence catalog at ${paths.catalog}: ${message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function writeCatalog(paths, catalog) {
|
|
62
|
+
await mkdir(paths.root, { recursive: true, mode: 0o700 });
|
|
63
|
+
const temporary = `${paths.catalog}.tmp`;
|
|
64
|
+
await writeFile(temporary, `${JSON.stringify(catalog, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
65
|
+
await rename(temporary, paths.catalog);
|
|
66
|
+
}
|
|
67
|
+
export async function initializeCatalog(paths = resolveCatalogPaths(), athlete) {
|
|
68
|
+
if (existsSync(paths.catalog))
|
|
69
|
+
return loadCatalog(paths);
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = await readdir(paths.root);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error.code === 'ENOENT')
|
|
76
|
+
entries = [];
|
|
77
|
+
else
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
if (entries.includes('catence.duckdb'))
|
|
81
|
+
throw new Error(legacyStoreMessage(paths));
|
|
82
|
+
const unrelated = entries.filter((entry) => !CONSOLE_ARTIFACTS.has(entry));
|
|
83
|
+
if (unrelated.length > 0) {
|
|
84
|
+
throw new Error(`Refusing to initialize ${paths.root}: found ${unrelated.join(', ')}. Remove these or choose an empty --home directory.`);
|
|
85
|
+
}
|
|
86
|
+
const id = athleteIdSchema.parse(athlete.id);
|
|
87
|
+
const record = { id, label: athlete.label.trim(), createdAt: new Date().toISOString() };
|
|
88
|
+
if (!record.label)
|
|
89
|
+
throw new Error('Athlete label is required.');
|
|
90
|
+
const catalog = { formatVersion: CATALOG_FORMAT_VERSION, defaultAthleteId: id, athletes: [record] };
|
|
91
|
+
await writeCatalog(paths, catalog);
|
|
92
|
+
return catalog;
|
|
93
|
+
}
|
|
94
|
+
export async function addAthlete(paths, athlete) {
|
|
95
|
+
const catalog = await loadCatalog(paths);
|
|
96
|
+
const id = athleteIdSchema.parse(athlete.id);
|
|
97
|
+
if (catalog.athletes.some((entry) => entry.id === id))
|
|
98
|
+
throw new Error(`Athlete ${id} already exists.`);
|
|
99
|
+
const label = athlete.label.trim();
|
|
100
|
+
if (!label)
|
|
101
|
+
throw new Error('Athlete label is required.');
|
|
102
|
+
const next = {
|
|
103
|
+
...catalog,
|
|
104
|
+
defaultAthleteId: athlete.setDefault ? id : catalog.defaultAthleteId,
|
|
105
|
+
athletes: [...catalog.athletes, { id, label, createdAt: new Date().toISOString() }],
|
|
106
|
+
};
|
|
107
|
+
await writeCatalog(paths, next);
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
export async function resolveAthlete(paths, athleteId) {
|
|
111
|
+
const catalog = await loadCatalog(paths);
|
|
112
|
+
const athlete = catalog.athletes.find((entry) => entry.id === athleteId);
|
|
113
|
+
if (!athlete)
|
|
114
|
+
throw new Error(`Unknown athlete ${athleteId}. Call list_athletes to inspect the configured catalog.`);
|
|
115
|
+
return { athlete, paths: athleteStorePaths(paths, athlete.id) };
|
|
116
|
+
}
|
|
117
|
+
export async function defaultAthlete(paths = resolveCatalogPaths()) {
|
|
118
|
+
const catalog = await loadCatalog(paths);
|
|
119
|
+
return resolveAthlete(paths, catalog.defaultAthleteId);
|
|
120
|
+
}
|
|
121
|
+
//# 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
|