catence 0.2.0-beta.2 → 0.2.0-beta.21
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 +28 -82
- package/config.example.json +25 -0
- 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 +49 -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 +48 -0
- package/dist/core/runtime/catalog.js +17 -7
- package/dist/core/runtime/configuration.d.ts +3 -1
- package/dist/core/runtime/configuration.js +5 -1
- package/dist/core/runtime/opencode-go.d.ts +58 -0
- package/dist/core/runtime/opencode-go.js +246 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/detached-sync.d.ts +50 -0
- package/dist/elt/application/detached-sync.js +99 -0
- package/dist/elt/application/management.d.ts +15 -0
- package/dist/elt/application/management.js +435 -19
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +5 -4
- package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
- package/dist/elt/ingestion/providers/intervals.js +37 -2
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +18 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/normalization/normalizers.js +111 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +184 -6
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +200 -0
- package/dist/interfaces/cli/commands.js +116 -1
- package/dist/interfaces/http/generation-status.d.ts +19 -0
- package/dist/interfaces/http/generation-status.js +55 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +184 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +50 -13
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +4 -1
- package/docs/architecture.md +11 -1
- package/docs/configuration.md +243 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +263 -0
- package/docs/deployment/local-mcp.md +329 -0
- package/docs/development.md +337 -0
- package/docs/distribution.md +58 -0
- package/docs/llm-providers.md +249 -6
- package/docs/mcp-activity-retrieval.md +37 -30
- package/package.json +5 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +43 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/registry.py +1 -0
- package/python/catence/providers/garmin/streams.py +38 -0
- package/python/catence/providers/garmin/swim_backfill.py +83 -0
- package/python/catence/providers/garmin/worker.py +372 -11
- package/scripts/discover-opencode-go.mjs +102 -0
- package/scripts/discover-opencode-zen.mjs +218 -0
- package/uv.lock +3 -3
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { catenceConfigSchema } from './configuration.js';
|
|
4
|
+
export const OPENCODE_GO_DEFAULT_BASE_URL = 'https://opencode.ai/zen/go/v1';
|
|
5
|
+
export const OPENCODE_GO_API_KEY_ENV = 'OPENCODE_GO_API_KEY';
|
|
6
|
+
export const OPENCODE_GO_API_BASE_ENV = 'OPENCODE_GO_API_BASE';
|
|
7
|
+
export const OPENCODE_GO_MESSAGES_API_BASE_ENV = 'OPENCODE_GO_MESSAGES_API_BASE';
|
|
8
|
+
// LiteLLM appends a fixed suffix to the profile base URL per provider, so the
|
|
9
|
+
// three APIs need different prefixes and two base URLs:
|
|
10
|
+
// openai/<id> -> {base}/chat/completions
|
|
11
|
+
// openai/responses/<id> -> {base}/responses
|
|
12
|
+
// anthropic/<id> -> {base minus /v1}/v1/messages
|
|
13
|
+
const RESPONSES_MODELS = new Set(['grok-4.5', 'gpt-5.6-luna']);
|
|
14
|
+
const MESSAGES_MODELS = new Set([
|
|
15
|
+
'minimax-m2.5',
|
|
16
|
+
'minimax-m2.7',
|
|
17
|
+
'minimax-m3',
|
|
18
|
+
'qwen3.5-plus',
|
|
19
|
+
'qwen3.6-plus',
|
|
20
|
+
'qwen3.7-plus',
|
|
21
|
+
'qwen3.7-max',
|
|
22
|
+
'qwen3.8-max',
|
|
23
|
+
]);
|
|
24
|
+
const BRAND_NAMES = new Map([
|
|
25
|
+
['deepseek', 'DeepSeek'],
|
|
26
|
+
['glm', 'GLM'],
|
|
27
|
+
['gpt', 'GPT'],
|
|
28
|
+
['grok', 'Grok'],
|
|
29
|
+
['hy3', 'Hy3'],
|
|
30
|
+
['kimi', 'Kimi'],
|
|
31
|
+
['mimo', 'MiMo'],
|
|
32
|
+
['minimax', 'MiniMax'],
|
|
33
|
+
['qwen', 'Qwen'],
|
|
34
|
+
]);
|
|
35
|
+
const CHAT_PROFILE_ID = 'opencode-go';
|
|
36
|
+
const MESSAGES_PROFILE_ID = 'opencode-go-messages';
|
|
37
|
+
/** Live model ids plus trusted routing metadata ("live discovery + trusted metadata"). */
|
|
38
|
+
export function classifyOpenCodeGoModel(id) {
|
|
39
|
+
if (RESPONSES_MODELS.has(id))
|
|
40
|
+
return { route: 'responses', guessed: false };
|
|
41
|
+
if (MESSAGES_MODELS.has(id))
|
|
42
|
+
return { route: 'messages', guessed: false };
|
|
43
|
+
if (/^(grok-|gpt-)/.test(id))
|
|
44
|
+
return { route: 'responses', guessed: true };
|
|
45
|
+
if (/^(minimax-|qwen)/.test(id))
|
|
46
|
+
return { route: 'messages', guessed: true };
|
|
47
|
+
return { route: 'chat', guessed: false };
|
|
48
|
+
}
|
|
49
|
+
function modelReference(id, route) {
|
|
50
|
+
if (route === 'responses')
|
|
51
|
+
return `openai/responses/${id}`;
|
|
52
|
+
if (route === 'messages')
|
|
53
|
+
return `anthropic/${id}`;
|
|
54
|
+
return `openai/${id}`;
|
|
55
|
+
}
|
|
56
|
+
function labelFor(id) {
|
|
57
|
+
return id
|
|
58
|
+
.split('-')
|
|
59
|
+
.map((segment) => BRAND_NAMES.get(segment) ?? segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
60
|
+
.join(' ');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build the two ready-made Console profiles from live model ids.
|
|
64
|
+
*
|
|
65
|
+
* The public `GET /models` endpoint returns only ids, not which API each model
|
|
66
|
+
* needs, so routing comes from the trusted table intersected with the live
|
|
67
|
+
* list; prefix-guessed routes are reported so callers can warn.
|
|
68
|
+
*/
|
|
69
|
+
export function buildOpenCodeGoConsoleProfiles(modelIds, baseUrl = OPENCODE_GO_DEFAULT_BASE_URL) {
|
|
70
|
+
const groups = { chat: [], responses: [], messages: [] };
|
|
71
|
+
const guessedRoutes = [];
|
|
72
|
+
for (const id of modelIds) {
|
|
73
|
+
if (!id)
|
|
74
|
+
continue;
|
|
75
|
+
const { route, guessed } = classifyOpenCodeGoModel(id);
|
|
76
|
+
groups[route].push(id);
|
|
77
|
+
if (guessed)
|
|
78
|
+
guessedRoutes.push(`${id} -> ${route}`);
|
|
79
|
+
}
|
|
80
|
+
for (const route of Object.keys(groups))
|
|
81
|
+
groups[route].sort();
|
|
82
|
+
const chatModels = {};
|
|
83
|
+
for (const id of [...groups.chat, ...groups.responses]) {
|
|
84
|
+
chatModels[id] = { label: labelFor(id), model: modelReference(id, groups.chat.includes(id) ? 'chat' : 'responses') };
|
|
85
|
+
}
|
|
86
|
+
const messagesModels = {};
|
|
87
|
+
for (const id of groups.messages) {
|
|
88
|
+
messagesModels[id] = { label: labelFor(id), model: modelReference(id, 'messages') };
|
|
89
|
+
}
|
|
90
|
+
const chatDefault = groups.chat[0] ?? groups.responses[0];
|
|
91
|
+
const profiles = {
|
|
92
|
+
[CHAT_PROFILE_ID]: {
|
|
93
|
+
label: 'OpenCode Go',
|
|
94
|
+
...(chatDefault ? { defaultModel: chatDefault } : {}),
|
|
95
|
+
models: chatModels,
|
|
96
|
+
apiKeyEnv: OPENCODE_GO_API_KEY_ENV,
|
|
97
|
+
apiBaseEnv: OPENCODE_GO_API_BASE_ENV,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
if (groups.messages.length) {
|
|
101
|
+
profiles[MESSAGES_PROFILE_ID] = {
|
|
102
|
+
label: 'OpenCode Go (Anthropic)',
|
|
103
|
+
defaultModel: groups.messages[0],
|
|
104
|
+
models: messagesModels,
|
|
105
|
+
apiKeyEnv: OPENCODE_GO_API_KEY_ENV,
|
|
106
|
+
apiBaseEnv: OPENCODE_GO_MESSAGES_API_BASE_ENV,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
profiles,
|
|
111
|
+
defaultProfile: CHAT_PROFILE_ID,
|
|
112
|
+
counts: { chat: groups.chat.length, responses: groups.responses.length, messages: groups.messages.length },
|
|
113
|
+
guessedRoutes,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export async function fetchOpenCodeGoModelIds(baseUrl = OPENCODE_GO_DEFAULT_BASE_URL) {
|
|
117
|
+
const modelsUrl = `${baseUrl.replace(/\/$/, '')}/models`;
|
|
118
|
+
let response;
|
|
119
|
+
try {
|
|
120
|
+
response = await fetch(modelsUrl, { headers: { accept: 'application/json' } });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throw new Error(`Could not reach ${modelsUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
if (!response.ok)
|
|
126
|
+
throw new Error(`GET ${modelsUrl} returned HTTP ${response.status}.`);
|
|
127
|
+
const payload = (await response.json());
|
|
128
|
+
const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string' && Boolean(id));
|
|
129
|
+
if (!ids.length)
|
|
130
|
+
throw new Error(`GET ${modelsUrl} returned no models.`);
|
|
131
|
+
return ids;
|
|
132
|
+
}
|
|
133
|
+
async function readConfigRoot(configPath) {
|
|
134
|
+
try {
|
|
135
|
+
const root = JSON.parse(await readFile(configPath, 'utf8'));
|
|
136
|
+
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
|
|
137
|
+
throw new Error(`${configPath} must contain a JSON object.`);
|
|
138
|
+
}
|
|
139
|
+
return root;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error.code === 'ENOENT')
|
|
143
|
+
return {};
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Per-model fields a human may have customized after the initial discovery. */
|
|
148
|
+
function preservedModelFields(previous) {
|
|
149
|
+
if (typeof previous !== 'object' || previous === null || Array.isArray(previous))
|
|
150
|
+
return {};
|
|
151
|
+
const raw = previous;
|
|
152
|
+
const preserved = {};
|
|
153
|
+
if (typeof raw.label === 'string' && raw.label.trim())
|
|
154
|
+
preserved.label = raw.label;
|
|
155
|
+
if (typeof raw.reasoningEffort === 'string')
|
|
156
|
+
preserved.reasoningEffort = raw.reasoningEffort;
|
|
157
|
+
if (raw.variants !== undefined)
|
|
158
|
+
preserved.variants = raw.variants;
|
|
159
|
+
return preserved;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Fetch the live OpenCode Go catalog and merge the two Console profiles into
|
|
163
|
+
* `configPath`, preserving every other section plus existing console fields
|
|
164
|
+
* (`limits`, other profiles) and the existing `defaultProfile` unless
|
|
165
|
+
* `setDefault` is passed. Model routing (`model`) is refreshed from the live
|
|
166
|
+
* catalog, per-model customizations (custom label, `reasoningEffort`,
|
|
167
|
+
* `variants`) survive for models still in the catalog, and models that
|
|
168
|
+
* disappeared upstream are pruned so the Console never offers stale routes.
|
|
169
|
+
* The written file is re-validated with the same strict schema the runtime
|
|
170
|
+
* applies on load, then replaced atomically.
|
|
171
|
+
*/
|
|
172
|
+
export async function mergeOpenCodeGoConsoleProfiles(options) {
|
|
173
|
+
const baseUrl = options.baseUrl ?? OPENCODE_GO_DEFAULT_BASE_URL;
|
|
174
|
+
const modelIds = await fetchOpenCodeGoModelIds(baseUrl);
|
|
175
|
+
const built = buildOpenCodeGoConsoleProfiles(modelIds, baseUrl);
|
|
176
|
+
const root = await readConfigRoot(options.configPath);
|
|
177
|
+
const existingConsole = typeof root.console === 'object' && root.console !== null && !Array.isArray(root.console)
|
|
178
|
+
? root.console
|
|
179
|
+
: {};
|
|
180
|
+
const existingProfiles = typeof existingConsole.profiles === 'object' && existingConsole.profiles !== null && !Array.isArray(existingConsole.profiles)
|
|
181
|
+
? existingConsole.profiles
|
|
182
|
+
: {};
|
|
183
|
+
const defaultProfile = typeof existingConsole.defaultProfile === 'string' && existingConsole.defaultProfile && !options.setDefault
|
|
184
|
+
? existingConsole.defaultProfile
|
|
185
|
+
: built.defaultProfile;
|
|
186
|
+
// Merge per model, not per profile: discovery owns routing and the id list,
|
|
187
|
+
// but a human's label, reasoningEffort, and variants must survive every
|
|
188
|
+
// re-discovery. Models that vanished upstream are pruned so the managed
|
|
189
|
+
// profiles mirror the live catalog instead of going stale; a failed or
|
|
190
|
+
// empty catalog fetch never reaches this loop (fetch throws first).
|
|
191
|
+
const mergedProfiles = {};
|
|
192
|
+
const prunedModelIds = [];
|
|
193
|
+
for (const [profileId, builtProfile] of Object.entries(built.profiles)) {
|
|
194
|
+
const previousProfile = existingProfiles[profileId];
|
|
195
|
+
const rawModels = typeof previousProfile === 'object' && previousProfile !== null
|
|
196
|
+
? previousProfile.models
|
|
197
|
+
: undefined;
|
|
198
|
+
const previousModels = typeof rawModels === 'object' && rawModels !== null && !Array.isArray(rawModels)
|
|
199
|
+
? rawModels
|
|
200
|
+
: {};
|
|
201
|
+
const models = {};
|
|
202
|
+
for (const [modelId, entry] of Object.entries(builtProfile.models)) {
|
|
203
|
+
models[modelId] = {
|
|
204
|
+
...preservedModelFields(previousModels[modelId]),
|
|
205
|
+
label: entry.label,
|
|
206
|
+
model: entry.model,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
for (const modelId of Object.keys(previousModels)) {
|
|
210
|
+
if (!(modelId in models))
|
|
211
|
+
prunedModelIds.push(`${profileId}:${modelId}`);
|
|
212
|
+
}
|
|
213
|
+
const previousDefaultModel = typeof previousProfile === 'object' && previousProfile !== null
|
|
214
|
+
? previousProfile.defaultModel
|
|
215
|
+
: undefined;
|
|
216
|
+
mergedProfiles[profileId] = {
|
|
217
|
+
...builtProfile,
|
|
218
|
+
...(typeof previousDefaultModel === 'string' && previousDefaultModel in models
|
|
219
|
+
? { defaultModel: previousDefaultModel }
|
|
220
|
+
: {}),
|
|
221
|
+
models,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const updatedConsole = {
|
|
225
|
+
...existingConsole,
|
|
226
|
+
defaultProfile,
|
|
227
|
+
profiles: { ...existingProfiles, ...mergedProfiles },
|
|
228
|
+
};
|
|
229
|
+
// Validate before writing anything: the runtime rejects invalid configs at
|
|
230
|
+
// load time, so a bad merge must never reach disk.
|
|
231
|
+
const candidate = { ...root, console: updatedConsole };
|
|
232
|
+
catenceConfigSchema.parse(candidate);
|
|
233
|
+
await mkdir(path.dirname(options.configPath), { recursive: true });
|
|
234
|
+
const temporaryPath = `${options.configPath}.tmp`;
|
|
235
|
+
await writeFile(temporaryPath, `${JSON.stringify(candidate, null, 2)}\n`, 'utf8');
|
|
236
|
+
await rename(temporaryPath, options.configPath);
|
|
237
|
+
return {
|
|
238
|
+
configPath: options.configPath,
|
|
239
|
+
mergedProfileIds: Object.keys(built.profiles),
|
|
240
|
+
defaultProfile,
|
|
241
|
+
counts: built.counts,
|
|
242
|
+
guessedRoutes: built.guessedRoutes,
|
|
243
|
+
prunedModelIds,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=opencode-go.js.map
|
|
@@ -42,27 +42,65 @@ export async function setAthleteSecret(paths, provider, field, value) {
|
|
|
42
42
|
await rename(temporary, destination);
|
|
43
43
|
await chmod(destination, 0o600);
|
|
44
44
|
}
|
|
45
|
+
function isTruthyEnvValue(value) {
|
|
46
|
+
if (!value)
|
|
47
|
+
return false;
|
|
48
|
+
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
function isGenericEnvFallbackAllowed(base) {
|
|
51
|
+
const direct = base.CATENCE_ALLOW_ENV_SECRETS ?? base.CATENCE_SECRETS_FROM_ENV ?? process.env.CATENCE_ALLOW_ENV_SECRETS ?? process.env.CATENCE_SECRETS_FROM_ENV;
|
|
52
|
+
return isTruthyEnvValue(direct);
|
|
53
|
+
}
|
|
54
|
+
function athleteEnvPrefix(paths) {
|
|
55
|
+
const athleteId = path.basename(path.dirname(paths.secrets));
|
|
56
|
+
const normalized = athleteId.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
|
|
57
|
+
return `CATENCE_ATHLETE_${normalized}_`;
|
|
58
|
+
}
|
|
59
|
+
function resolveWithEnvFallback(fileValue, envName, perAthletePrefix, base, allowGeneric) {
|
|
60
|
+
if (fileValue)
|
|
61
|
+
return fileValue;
|
|
62
|
+
const perAthleteName = `${perAthletePrefix}${envName}`;
|
|
63
|
+
const perAthleteValue = base[perAthleteName] ?? process.env[perAthleteName];
|
|
64
|
+
if (perAthleteValue)
|
|
65
|
+
return perAthleteValue;
|
|
66
|
+
if (!allowGeneric)
|
|
67
|
+
return undefined;
|
|
68
|
+
const genericValue = base[envName] ?? process.env[envName];
|
|
69
|
+
if (genericValue)
|
|
70
|
+
return genericValue;
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
45
73
|
/** Build only the provider environment needed by a selected athlete worker. */
|
|
46
74
|
export async function athleteProviderEnvironment(paths, base = process.env) {
|
|
47
75
|
const secrets = await readAthleteSecrets(paths);
|
|
48
76
|
const environment = { ...base };
|
|
77
|
+
const allowGenericEnv = isGenericEnvFallbackAllowed(base);
|
|
78
|
+
const perAthletePrefix = athleteEnvPrefix(paths);
|
|
49
79
|
// Never let credentials inherited by the shared process leak into an
|
|
50
|
-
// athlete store that has not explicitly configured that provider
|
|
80
|
+
// athlete store that has not explicitly configured that provider, unless
|
|
81
|
+
// the operator has opted in via CATENCE_ALLOW_ENV_SECRETS or is using a
|
|
82
|
+
// per-athlete scoped variable (CATENCE_ATHLETE_<ID>_GARMIN_EMAIL etc.).
|
|
51
83
|
for (const name of ['GARMIN_EMAIL', 'GARMIN_PASSWORD', 'INTERVALS_API_KEY', 'INTERVALS_ATHLETE_ID', 'STRAVA_CLIENT_ID', 'STRAVA_CLIENT_SECRET']) {
|
|
52
84
|
delete environment[name];
|
|
53
85
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
environment.
|
|
62
|
-
if (
|
|
63
|
-
environment.
|
|
64
|
-
if (
|
|
65
|
-
environment.
|
|
86
|
+
const garminEmail = resolveWithEnvFallback(secrets.garmin?.email, 'GARMIN_EMAIL', perAthletePrefix, base, allowGenericEnv);
|
|
87
|
+
const garminPassword = resolveWithEnvFallback(secrets.garmin?.password, 'GARMIN_PASSWORD', perAthletePrefix, base, allowGenericEnv);
|
|
88
|
+
const intervalsApiKey = resolveWithEnvFallback(secrets.intervals?.apiKey, 'INTERVALS_API_KEY', perAthletePrefix, base, allowGenericEnv);
|
|
89
|
+
const intervalsAthleteId = resolveWithEnvFallback(secrets.intervals?.athleteId, 'INTERVALS_ATHLETE_ID', perAthletePrefix, base, allowGenericEnv);
|
|
90
|
+
const stravaClientId = resolveWithEnvFallback(secrets.strava?.clientId, 'STRAVA_CLIENT_ID', perAthletePrefix, base, allowGenericEnv);
|
|
91
|
+
const stravaClientSecret = resolveWithEnvFallback(secrets.strava?.clientSecret, 'STRAVA_CLIENT_SECRET', perAthletePrefix, base, allowGenericEnv);
|
|
92
|
+
if (garminEmail)
|
|
93
|
+
environment.GARMIN_EMAIL = garminEmail;
|
|
94
|
+
if (garminPassword)
|
|
95
|
+
environment.GARMIN_PASSWORD = garminPassword;
|
|
96
|
+
if (intervalsApiKey)
|
|
97
|
+
environment.INTERVALS_API_KEY = intervalsApiKey;
|
|
98
|
+
if (intervalsAthleteId)
|
|
99
|
+
environment.INTERVALS_ATHLETE_ID = intervalsAthleteId;
|
|
100
|
+
if (stravaClientId)
|
|
101
|
+
environment.STRAVA_CLIENT_ID = stravaClientId;
|
|
102
|
+
if (stravaClientSecret)
|
|
103
|
+
environment.STRAVA_CLIENT_SECRET = stravaClientSecret;
|
|
66
104
|
return environment;
|
|
67
105
|
}
|
|
68
106
|
//# sourceMappingURL=secrets.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type UpdateChannel = 'stable' | 'beta';
|
|
2
|
+
export type ParsedRelease = {
|
|
3
|
+
major: number;
|
|
4
|
+
minor: number;
|
|
5
|
+
patch: number;
|
|
6
|
+
/** Present only for prerelease builds; a stable release beats every beta of the same tuple. */
|
|
7
|
+
beta?: number;
|
|
8
|
+
};
|
|
9
|
+
export type SelfUpdatePlan = {
|
|
10
|
+
channel: UpdateChannel;
|
|
11
|
+
runtime: {
|
|
12
|
+
current: string;
|
|
13
|
+
target?: string;
|
|
14
|
+
updateAvailable: boolean;
|
|
15
|
+
};
|
|
16
|
+
console: {
|
|
17
|
+
installed?: string;
|
|
18
|
+
target?: string;
|
|
19
|
+
updateAvailable: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** True when neither component has anything to apply. */
|
|
22
|
+
ok: boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare function parseReleaseVersion(version: string): ParsedRelease | null;
|
|
25
|
+
export declare function compareReleaseVersions(left: ParsedRelease, right: ParsedRelease): -1 | 0 | 1;
|
|
26
|
+
export declare function channelForVersion(version: string): UpdateChannel;
|
|
27
|
+
export declare function latestRelease(versions: string[], channel: UpdateChannel): string | undefined;
|
|
28
|
+
export declare function planSelfUpdate(options: {
|
|
29
|
+
runtimeVersion: string;
|
|
30
|
+
channel: UpdateChannel;
|
|
31
|
+
npmDistTags: Record<string, string>;
|
|
32
|
+
pypiReleases: string[];
|
|
33
|
+
consoleInstalled?: string;
|
|
34
|
+
}): SelfUpdatePlan;
|
|
35
|
+
export declare function fetchNpmDistTags(packageName: string, fetchImpl?: typeof fetch): Promise<Record<string, string>>;
|
|
36
|
+
export declare function fetchPypiReleases(packageName: string, fetchImpl?: typeof fetch): Promise<string[]>;
|
|
37
|
+
/** Run an update command (npm or uv) with the user's terminal attached. */
|
|
38
|
+
export declare function runSelfUpdateCommand(command: string, args: string[]): Promise<number>;
|
|
39
|
+
export declare function hasCommand(command: string): Promise<boolean>;
|
|
40
|
+
/** Read `catence-console --version` from PATH; undefined when the Console is not installed. */
|
|
41
|
+
export declare function readInstalledConsoleVersion(): Promise<string | undefined>;
|
|
42
|
+
/** Global npm package root, used to decide whether `catence` can update itself in place. */
|
|
43
|
+
export declare function npmGlobalPrefix(): Promise<string | undefined>;
|
|
44
|
+
export declare function isGlobalNpmInstall(moduleUrl: string, npmPrefix: string | undefined): boolean;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update support for the Catence runtime and its Console.
|
|
3
|
+
*
|
|
4
|
+
* The runtime tracks npm dist-tags on the `catence` package; the Console
|
|
5
|
+
* tracks PEP 440 releases on PyPI (`catence-console`). A release is "newer"
|
|
6
|
+
* only when it strictly dominates the installed version inside the tracked
|
|
7
|
+
* channel: `stable` for non-prerelease installs, or the beta prerelease
|
|
8
|
+
* channel for beta installs. Channel names match npm dist-tags, so a beta
|
|
9
|
+
* runtime consults the `beta` tag and an explicit `--channel stable` moves a
|
|
10
|
+
* beta install onto the `latest` tag.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
const NPM_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
|
|
15
|
+
const PEP440_BETA = /^(\d+)\.(\d+)\.(\d+)b(\d+)$/;
|
|
16
|
+
export function parseReleaseVersion(version) {
|
|
17
|
+
const npm = NPM_VERSION.exec(version.trim());
|
|
18
|
+
if (npm) {
|
|
19
|
+
return {
|
|
20
|
+
major: Number(npm[1]),
|
|
21
|
+
minor: Number(npm[2]),
|
|
22
|
+
patch: Number(npm[3]),
|
|
23
|
+
beta: npm[4] === undefined ? undefined : Number(npm[4]),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const pep440 = PEP440_BETA.exec(version.trim());
|
|
27
|
+
if (pep440) {
|
|
28
|
+
return { major: Number(pep440[1]), minor: Number(pep440[2]), patch: Number(pep440[3]), beta: Number(pep440[4]) };
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function compareReleaseVersions(left, right) {
|
|
33
|
+
for (const field of ['major', 'minor', 'patch']) {
|
|
34
|
+
if (left[field] !== right[field])
|
|
35
|
+
return left[field] < right[field] ? -1 : 1;
|
|
36
|
+
}
|
|
37
|
+
const leftBeta = left.beta ?? Number.POSITIVE_INFINITY;
|
|
38
|
+
const rightBeta = right.beta ?? Number.POSITIVE_INFINITY;
|
|
39
|
+
if (leftBeta === rightBeta)
|
|
40
|
+
return 0;
|
|
41
|
+
return leftBeta < rightBeta ? -1 : 1;
|
|
42
|
+
}
|
|
43
|
+
export function channelForVersion(version) {
|
|
44
|
+
return parseReleaseVersion(version)?.beta !== undefined ? 'beta' : 'stable';
|
|
45
|
+
}
|
|
46
|
+
export function latestRelease(versions, channel) {
|
|
47
|
+
let best;
|
|
48
|
+
for (const version of versions) {
|
|
49
|
+
const parsed = parseReleaseVersion(version);
|
|
50
|
+
if (!parsed)
|
|
51
|
+
continue;
|
|
52
|
+
const isBeta = parsed.beta !== undefined;
|
|
53
|
+
if (channel === 'beta' ? !isBeta : isBeta)
|
|
54
|
+
continue;
|
|
55
|
+
if (!best || compareReleaseVersions(parsed, best.parsed) > 0)
|
|
56
|
+
best = { version, parsed };
|
|
57
|
+
}
|
|
58
|
+
return best?.version;
|
|
59
|
+
}
|
|
60
|
+
export function planSelfUpdate(options) {
|
|
61
|
+
const distTag = options.channel === 'beta' ? 'beta' : 'latest';
|
|
62
|
+
const runtimeTarget = options.npmDistTags[distTag];
|
|
63
|
+
const consoleTarget = latestRelease(options.pypiReleases, options.channel);
|
|
64
|
+
const runtimeUpdateAvailable = isNewer(runtimeTarget, parseReleaseVersion(options.runtimeVersion));
|
|
65
|
+
const consoleUpdateAvailable = options.consoleInstalled
|
|
66
|
+
? isNewer(consoleTarget, parseReleaseVersion(options.consoleInstalled))
|
|
67
|
+
: consoleTarget !== undefined;
|
|
68
|
+
return {
|
|
69
|
+
channel: options.channel,
|
|
70
|
+
runtime: { current: options.runtimeVersion, target: runtimeTarget, updateAvailable: runtimeUpdateAvailable },
|
|
71
|
+
console: { installed: options.consoleInstalled, target: consoleTarget, updateAvailable: consoleUpdateAvailable },
|
|
72
|
+
ok: !runtimeUpdateAvailable && !consoleUpdateAvailable,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function isNewer(target, current) {
|
|
76
|
+
if (!target || !current)
|
|
77
|
+
return false;
|
|
78
|
+
const parsed = parseReleaseVersion(target);
|
|
79
|
+
return parsed !== null && compareReleaseVersions(parsed, current) > 0;
|
|
80
|
+
}
|
|
81
|
+
export async function fetchNpmDistTags(packageName, fetchImpl = fetch) {
|
|
82
|
+
const response = await fetchImpl(`https://registry.npmjs.org/${packageName}`);
|
|
83
|
+
if (!response.ok)
|
|
84
|
+
throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}.`);
|
|
85
|
+
const payload = (await response.json());
|
|
86
|
+
return payload['dist-tags'] ?? {};
|
|
87
|
+
}
|
|
88
|
+
export async function fetchPypiReleases(packageName, fetchImpl = fetch) {
|
|
89
|
+
const response = await fetchImpl(`https://pypi.org/pypi/${packageName}/json`);
|
|
90
|
+
if (!response.ok)
|
|
91
|
+
throw new Error(`PyPI returned HTTP ${response.status} for ${packageName}.`);
|
|
92
|
+
const payload = (await response.json());
|
|
93
|
+
return Object.keys(payload.releases ?? {});
|
|
94
|
+
}
|
|
95
|
+
/** Run an update command (npm or uv) with the user's terminal attached. */
|
|
96
|
+
export function runSelfUpdateCommand(command, args) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const child = spawn(command, args, { stdio: 'inherit' });
|
|
99
|
+
child.once('error', reject);
|
|
100
|
+
child.once('close', (code) => resolve(code ?? 1));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function hasCommand(command) {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
const child = spawn(command, ['--version'], { stdio: 'ignore' });
|
|
106
|
+
child.once('error', () => resolve(false));
|
|
107
|
+
child.once('close', (code) => resolve(code === 0));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Read `catence-console --version` from PATH; undefined when the Console is not installed. */
|
|
111
|
+
export function readInstalledConsoleVersion() {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
const child = spawn('catence-console', ['--version'], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
114
|
+
let stdout = '';
|
|
115
|
+
child.stdout?.on('data', (chunk) => (stdout += chunk.toString('utf8')));
|
|
116
|
+
child.once('error', () => resolve(undefined));
|
|
117
|
+
child.once('close', (code) => {
|
|
118
|
+
const match = /^catence-console (\S+)/.exec(stdout.trim());
|
|
119
|
+
resolve(code === 0 && match ? match[1] : undefined);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** Global npm package root, used to decide whether `catence` can update itself in place. */
|
|
124
|
+
export function npmGlobalPrefix() {
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
const child = spawn('npm', ['prefix', '-g'], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
127
|
+
let stdout = '';
|
|
128
|
+
child.stdout?.on('data', (chunk) => (stdout += chunk.toString('utf8')));
|
|
129
|
+
child.once('error', () => resolve(undefined));
|
|
130
|
+
child.once('close', (code) => resolve(code === 0 ? stdout.trim() : undefined));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
export function isGlobalNpmInstall(moduleUrl, npmPrefix) {
|
|
134
|
+
if (!npmPrefix)
|
|
135
|
+
return false;
|
|
136
|
+
return fileURLToPath(moduleUrl).startsWith(`${npmPrefix}/lib/node_modules/`);
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=self-update.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type SpawnOptions } from 'node:child_process';
|
|
2
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
3
|
+
export declare const DETACHED_SYNC_PROVIDERS: readonly ["intervals", "garmin", "strava", "all"];
|
|
4
|
+
export type DetachedSyncProvider = (typeof DETACHED_SYNC_PROVIDERS)[number];
|
|
5
|
+
export type DetachedSyncRequest = {
|
|
6
|
+
paths: CatencePaths;
|
|
7
|
+
athleteId: string;
|
|
8
|
+
provider?: DetachedSyncProvider;
|
|
9
|
+
from?: string;
|
|
10
|
+
refresh?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The `--home` value the detached `catence-data` child must receive.
|
|
13
|
+
*
|
|
14
|
+
* Catalog deployments pass the catalog root here: `paths` points at the
|
|
15
|
+
* resolved athlete store (for locks/logs), but the child resolves
|
|
16
|
+
* `--athlete` within a catalog, so pointing `--home` at the store itself
|
|
17
|
+
* makes it fail with the legacy-store guard. Standalone stores omit this
|
|
18
|
+
* and default to `paths.root`.
|
|
19
|
+
*/
|
|
20
|
+
cliHome?: string;
|
|
21
|
+
};
|
|
22
|
+
export type DetachedSyncHandle = {
|
|
23
|
+
started: true;
|
|
24
|
+
athleteId: string;
|
|
25
|
+
provider: DetachedSyncProvider;
|
|
26
|
+
logFile: string;
|
|
27
|
+
};
|
|
28
|
+
/** Raised when another sync run is already active for this store. */
|
|
29
|
+
export declare class DetachedSyncBusyError extends Error {
|
|
30
|
+
readonly runId: string;
|
|
31
|
+
constructor(runId: string);
|
|
32
|
+
}
|
|
33
|
+
/** Minimal structural spawner so tests can inject a fake child process. */
|
|
34
|
+
export type DetachedSyncSpawner = (command: string, arguments_: readonly string[], options: SpawnOptions) => {
|
|
35
|
+
unref(): void;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Spawn one detached `catence-data sync` process for an athlete store and
|
|
39
|
+
* return immediately.
|
|
40
|
+
*
|
|
41
|
+
* The child outlives this process (SSH deaths, Console restarts), logs to a
|
|
42
|
+
* file under the catalog home, and is guarded by the same live-progress
|
|
43
|
+
* heartbeat check the progress tools use. The guard and the spawn are not
|
|
44
|
+
* atomic; the single-writer DuckDB lock remains the authoritative guard, so a
|
|
45
|
+
* racing second child fails cleanly in its own log file.
|
|
46
|
+
*/
|
|
47
|
+
export declare function startDetachedSync(request: DetachedSyncRequest, dependencies?: {
|
|
48
|
+
now?: () => Date;
|
|
49
|
+
spawnProcess?: DetachedSyncSpawner;
|
|
50
|
+
}): Promise<DetachedSyncHandle>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { open, mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
|
|
7
|
+
import { syncProgress } from './management.js';
|
|
8
|
+
export const DETACHED_SYNC_PROVIDERS = ['intervals', 'garmin', 'strava', 'all'];
|
|
9
|
+
/** Raised when another sync run is already active for this store. */
|
|
10
|
+
export class DetachedSyncBusyError extends Error {
|
|
11
|
+
runId;
|
|
12
|
+
constructor(runId) {
|
|
13
|
+
super(`A Catence sync run is already active (${runId}). Watch it with the progress tools instead of starting another.`);
|
|
14
|
+
this.name = 'DetachedSyncBusyError';
|
|
15
|
+
this.runId = runId;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve how to launch the `catence-data` CLI next to this module.
|
|
20
|
+
*
|
|
21
|
+
* Built installs: both modules live under dist/, so the child is plain
|
|
22
|
+
* `node …/dist/interfaces/cli/main.js`. Source checkouts run through tsx,
|
|
23
|
+
* where the same relative path points at a nonexistent `.js` file — there the
|
|
24
|
+
* TypeScript entry is launched through the checkout's local tsx binary
|
|
25
|
+
* instead. Tests inject their own spawner and never reach this resolution.
|
|
26
|
+
*/
|
|
27
|
+
function cliInvocation() {
|
|
28
|
+
const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const builtEntry = path.resolve(moduleDirectory, '../../interfaces/cli/main.js');
|
|
30
|
+
if (existsSync(builtEntry))
|
|
31
|
+
return { command: process.execPath, entry: builtEntry };
|
|
32
|
+
const sourceEntry = path.resolve(moduleDirectory, '../../interfaces/cli/main.ts');
|
|
33
|
+
const tsxBinary = path.resolve(moduleDirectory, '../../../node_modules/.bin/tsx');
|
|
34
|
+
if (!existsSync(sourceEntry) || !existsSync(tsxBinary)) {
|
|
35
|
+
throw new Error(`Could not locate the catence-data CLI entry (tried ${builtEntry} and ${sourceEntry}). Install the package or run from a checkout with node_modules present.`);
|
|
36
|
+
}
|
|
37
|
+
return { command: tsxBinary, entry: sourceEntry };
|
|
38
|
+
}
|
|
39
|
+
function assertValidRequest(request) {
|
|
40
|
+
if (!request.athleteId || !/^[a-z][a-z0-9-]{0,62}$/.test(request.athleteId)) {
|
|
41
|
+
throw new Error('athleteId is required and must be a lowercase athlete identifier.');
|
|
42
|
+
}
|
|
43
|
+
if (request.provider !== undefined && !DETACHED_SYNC_PROVIDERS.includes(request.provider)) {
|
|
44
|
+
throw new Error('provider must be intervals, garmin, strava, or all.');
|
|
45
|
+
}
|
|
46
|
+
if (request.from !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(request.from)) {
|
|
47
|
+
throw new Error('from must use YYYY-MM-DD.');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Spawn one detached `catence-data sync` process for an athlete store and
|
|
52
|
+
* return immediately.
|
|
53
|
+
*
|
|
54
|
+
* The child outlives this process (SSH deaths, Console restarts), logs to a
|
|
55
|
+
* file under the catalog home, and is guarded by the same live-progress
|
|
56
|
+
* heartbeat check the progress tools use. The guard and the spawn are not
|
|
57
|
+
* atomic; the single-writer DuckDB lock remains the authoritative guard, so a
|
|
58
|
+
* racing second child fails cleanly in its own log file.
|
|
59
|
+
*/
|
|
60
|
+
export async function startDetachedSync(request, dependencies = {}) {
|
|
61
|
+
assertValidRequest(request);
|
|
62
|
+
const provider = request.provider ?? 'all';
|
|
63
|
+
const snapshot = await syncProgress(request.paths);
|
|
64
|
+
// Belt-and-braces: syncProgress already age-filters stale runs, so this
|
|
65
|
+
// predicate only re-asserts the sidecar contract at the decision point —
|
|
66
|
+
// a heartbeat older than the stale timeout must never block a new run.
|
|
67
|
+
const activeRun = snapshot.running.find((state) => {
|
|
68
|
+
const ageMs = Date.now() - Date.parse(state.heartbeatAt);
|
|
69
|
+
return Number.isFinite(ageMs) && ageMs <= STALE_RUN_TIMEOUT_MS;
|
|
70
|
+
});
|
|
71
|
+
if (activeRun)
|
|
72
|
+
throw new DetachedSyncBusyError(activeRun.runId);
|
|
73
|
+
const stamp = (dependencies.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, '-');
|
|
74
|
+
const logFile = path.join(request.paths.root, 'logs', `sync-${request.athleteId}-${stamp}.log`);
|
|
75
|
+
await mkdir(path.dirname(logFile), { recursive: true });
|
|
76
|
+
await writeFile(logFile, `[${new Date().toISOString()}] catence-data --athlete ${request.athleteId} sync --provider ${provider}${request.from ? ` --from ${request.from}` : ''}${request.refresh ? ' --refresh' : ''}\n`, 'utf8');
|
|
77
|
+
const arguments_ = [cliInvocation().entry, '--home', request.cliHome ?? request.paths.root, '--athlete', request.athleteId, 'sync', '--provider', provider];
|
|
78
|
+
if (request.from)
|
|
79
|
+
arguments_.push('--from', request.from);
|
|
80
|
+
if (request.refresh)
|
|
81
|
+
arguments_.push('--refresh');
|
|
82
|
+
// The file handle stays open only for the spawn; the detached child holds
|
|
83
|
+
// its own duplicate of the descriptor afterwards.
|
|
84
|
+
const logHandle = await open(logFile, 'a');
|
|
85
|
+
try {
|
|
86
|
+
const spawnProcess = dependencies.spawnProcess ?? spawn;
|
|
87
|
+
const child = spawnProcess(cliInvocation().command, arguments_, {
|
|
88
|
+
detached: true,
|
|
89
|
+
stdio: ['ignore', logHandle.fd, logHandle.fd],
|
|
90
|
+
env: process.env,
|
|
91
|
+
});
|
|
92
|
+
child.unref();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await logHandle.close();
|
|
96
|
+
}
|
|
97
|
+
return { started: true, athleteId: request.athleteId, provider, logFile };
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=detached-sync.js.map
|