@krtclcdy/tersio 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/cli/common.js +13 -1
  2. package/cli/doctor.js +7 -7
  3. package/cli/install.js +28 -28
  4. package/extensions/ai-addons-updater/index.ts +377 -0
  5. package/extensions/caveman-session/index.ts +161 -0
  6. package/extensions/combo-toggle/index.ts +217 -0
  7. package/extensions/lib/utils.ts +162 -0
  8. package/extensions/rtk-session/index.ts +164 -0
  9. package/extensions/shared/carbon.ts +123 -0
  10. package/extensions/shared/mode-reinforcement.ts +29 -0
  11. package/extensions/shared/plugin-settings.ts +57 -0
  12. package/extensions/shared/pricing.ts +168 -0
  13. package/extensions/shared/rtk-gain.ts +105 -0
  14. package/extensions/shared/session-state.ts +201 -0
  15. package/extensions/shared/types.ts +85 -0
  16. package/extensions/shared/usage-ledger.ts +406 -0
  17. package/extensions/shared/usage-store.ts +325 -0
  18. package/extensions/tersio-commands/index.ts +110 -0
  19. package/package.json +19 -24
  20. package/extensions/ai-addons-updater/index.js +0 -331
  21. package/extensions/caveman-session/index.js +0 -152
  22. package/extensions/combo-toggle/index.js +0 -165
  23. package/extensions/lib/utils.js +0 -154
  24. package/extensions/rtk-session/index.js +0 -148
  25. package/extensions/shared/carbon.js +0 -88
  26. package/extensions/shared/mode-reinforcement.js +0 -26
  27. package/extensions/shared/plugin-settings.js +0 -51
  28. package/extensions/shared/pricing.js +0 -163
  29. package/extensions/shared/rtk-gain.js +0 -79
  30. package/extensions/shared/session-state.js +0 -177
  31. package/extensions/shared/types.js +0 -3
  32. package/extensions/shared/usage-ledger.js +0 -354
  33. package/extensions/shared/usage-store.js +0 -294
  34. package/extensions/tersio-commands/index.js +0 -102
@@ -0,0 +1,161 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.ts';
4
+ import { dirname, join } from 'node:path';
5
+ import { readCavemanDefault } from '../shared/plugin-settings.ts';
6
+ import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
7
+
8
+ const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
9
+ const RULE_PATH = join(CAVERN_DIR, 'rule.md');
10
+
11
+ const FALLBACK_FULL_RULE = `Respond terse like smart caveman. All technical substance stay. Only fluff die.
12
+
13
+ Rules:
14
+ - Drop articles (a/an/the), filler (just/really/basically), pleasantries, hedging.
15
+ - Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
16
+ - Pattern: [thing] [action] [reason]. [next step].
17
+ - Not: 'Sure! I'd be happy to help you with that.'
18
+ - Yes: 'Bug in auth middleware. Fix:'
19
+
20
+ Auto-clarity: drop caveman for security warnings, irreversible actions, or when user seems confused. Resume after.`;
21
+
22
+ // ponytail: synchronous read each full-mode injection; ceiling = small file, cold session start. Upgrade path: cache file contents + mtime, invalidate on change.
23
+ function readFullRule(): string {
24
+ try { return readFileSync(RULE_PATH, 'utf8'); } catch { return FALLBACK_FULL_RULE; }
25
+ }
26
+
27
+ const DEFAULT_MODE = 'off';
28
+ const INSTRUCTIONS: Record<string, string | (() => string)> = {
29
+ lite: `Caveman lite active for this session.
30
+ Respond concise. Drop pleasantries, filler, and hedging. Keep complete technical substance. Code, commands, paths, errors, commits, and PR text stay normal/exact.`,
31
+ full: () => `Caveman full active for this session.\n${readFullRule()}`,
32
+ ultra: `Caveman ultra active for this session.
33
+ Maximum terse prose. Fragments preferred. No pleasantries, no tour, no recap unless needed. Keep all technical substance exact. Code, commands, commits, PR text, paths, and errors stay normal/exact. Drop caveman for security warnings, irreversible actions, or user confusion.`,
34
+ wenyan: `Caveman wenyan active for this session.
35
+ Use ultra-terse classical-Chinese-style prose only where it preserves clarity for the user. Keep technical terms, code, commands, commits, PR text, paths, and errors exact. If clarity would suffer, use caveman full instead.`,
36
+ };
37
+
38
+ function resolveMode(entries: SessionEntry[] | null | undefined, fallback: string = DEFAULT_MODE): string {
39
+ return lastCustomValue(entries, 'caveman-mode', (data) => normalizeMode('caveman', data?.mode)) ?? fallback;
40
+ }
41
+
42
+ function isOffCommand(text: unknown): boolean {
43
+ const t = normalizeInputCommand(text);
44
+ return t === 'stop caveman' || t === 'normal mode' || t === 'caveman off';
45
+ }
46
+
47
+ export default function cavemanSessionExtension(pi: ExtensionApi): void {
48
+ let currentMode = DEFAULT_MODE;
49
+ let isActive = false;
50
+ let lastCtx: ExtensionCtx | undefined = undefined;
51
+
52
+ function syncStatus(ctx?: ExtensionCtx): void {
53
+ lastCtx = paintableCtx(lastCtx, ctx);
54
+ const c = lastCtx;
55
+ if (!c?.ui?.setStatus) return;
56
+ // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
57
+ if (isComboPresetActive() || currentMode === 'off') {
58
+ c.ui.setStatus('caveman', undefined);
59
+ return;
60
+ }
61
+ paintStatusBar(c.ui, 'caveman', '🪨', `caveman: ${currentMode.toUpperCase()}`, isActive);
62
+ }
63
+
64
+ function setMode(mode: string, ctx?: ExtensionCtx): boolean {
65
+ const normalized = normalizeMode('caveman', mode);
66
+ if (!normalized) return false;
67
+ currentMode = normalized;
68
+ pi.appendEntry?.('caveman-mode', { mode: normalized });
69
+ setSharedComboMode('caveman', normalized);
70
+ syncStatus(ctx);
71
+ const active = activeModesSummary(getSharedComboState());
72
+ const msg = normalized === 'off'
73
+ ? `Caveman off. Active: ${active}.`
74
+ : `Caveman ${normalized} on — terse replies for this session. Active: ${active}.`;
75
+ ctx?.ui?.notify?.(msg, 'info');
76
+ return true;
77
+ }
78
+
79
+ pi.setLabel?.('Caveman session toggle');
80
+
81
+ // Live mirror: a /tersio or /combo switch publishes shared state — adopt
82
+ // it at once so the next turn injects the new mode with no session reload.
83
+ // Stable identity, so the bridge set dedupes across re-inits.
84
+ function syncFromShared(state: { caveman: string }): void {
85
+ const mode = normalizeMode('caveman', state.caveman);
86
+ if (mode) {
87
+ currentMode = mode;
88
+ syncStatus();
89
+ }
90
+ }
91
+ setSharedComboListener(syncFromShared);
92
+ pi.registerCommand?.('caveman', {
93
+ description: 'Toggle terse caveman replies for this session',
94
+ handler: async (args, ctx) => {
95
+ const arg = String(args || '').trim().toLowerCase();
96
+ if (!arg || arg === 'on') {
97
+ setMode('full', ctx);
98
+ return;
99
+ }
100
+ if (arg === 'status') {
101
+ ctx?.ui?.notify?.(`Caveman: ${currentMode}`, 'info');
102
+ return;
103
+ }
104
+ if (!setMode(arg, ctx)) {
105
+ ctx?.ui?.notify?.('Usage: /caveman [lite|full|ultra|wenyan|off|status]', 'warning');
106
+ }
107
+ },
108
+ });
109
+
110
+ pi.on<InputEvent>('input', async (event) => {
111
+ if (event?.source === 'extension') return;
112
+ if (currentMode !== 'off' && isOffCommand(event?.text)) setMode('off');
113
+ });
114
+
115
+ function restoreMode(ctx?: ExtensionCtx): void {
116
+ const entries = sessionEntries(ctx);
117
+ // Publish the persisted combo state (incl. combo-level) before painting:
118
+ // bar suppression reads the in-process bridge, which is empty in a fresh
119
+ // host until the combo extension reconciles — and its reconcile is
120
+ // UI-gated. Deriving it here makes suppression independent of load order.
121
+ reconcileSharedComboEntries(entries);
122
+ // Persisted session state wins; a fresh session falls back to the
123
+ // installer/user-configured default (off unless configured).
124
+ const persisted = resolveMode(entries, '');
125
+ currentMode = persisted || readCavemanDefault();
126
+ syncStatus(ctx);
127
+ }
128
+
129
+ pi.on('session_start', async (_event, ctx) => {
130
+ restoreMode(ctx);
131
+ ctx?.ui?.notify?.(`Caveman loaded: ${currentMode}`, 'info');
132
+ });
133
+
134
+ pi.on('session_branch', async (_event, ctx) => {
135
+ restoreMode(ctx);
136
+ });
137
+
138
+ pi.on('session_tree', async (_event, ctx) => {
139
+ restoreMode(ctx);
140
+ });
141
+
142
+ pi.on('agent_start', async (_event, ctx) => {
143
+ isActive = true;
144
+ syncStatus(ctx);
145
+ });
146
+
147
+ pi.on('agent_end', async (_event, ctx) => {
148
+ isActive = false;
149
+ syncStatus(ctx);
150
+ });
151
+
152
+ pi.on('before_agent_start', async (event: SystemPromptEvent) => {
153
+ const mode = isOmpSubagentPrompt(event.systemPrompt) ? getSharedComboState().caveman : currentMode;
154
+ if (!mode || mode === 'off') return;
155
+ const def = INSTRUCTIONS[mode];
156
+ if (!def) return;
157
+ const instruction = typeof def === 'function' ? def() : def;
158
+ const base = asPromptArray(event.systemPrompt);
159
+ return { systemPrompt: [...base, instruction] };
160
+ });
161
+ }
@@ -0,0 +1,217 @@
1
+ // /combo session toggle — set all three (caveman, rtk, ponytail) at once.
2
+ // Modes: off | medium | balanced | max
3
+
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+ import {
8
+ activeModesSummary,
9
+ asPromptArray,
10
+ COMBO_LEVELS,
11
+ getSharedComboState,
12
+ isComboPresetActive,
13
+ isOmpSubagentPrompt,
14
+ normalizeComboLevel,
15
+ paintableCtx,
16
+ reconcileSharedComboEntries,
17
+ sessionEntries,
18
+ setSharedComboLevel,
19
+ setSharedComboListener,
20
+ setSharedComboMode,
21
+ systemPromptIncludes,
22
+ } from '../shared/session-state.ts';
23
+ import { readComboDefault, readPonytailDefault } from '../shared/plugin-settings.ts';
24
+ import type { ComboState, ExtensionApi, ExtensionCtx, SystemPromptEvent } from '../shared/types.ts';
25
+
26
+ const require = createRequire(import.meta.url);
27
+
28
+ const PONYTAIL_FALLBACK_INTENSITY: Record<string, string> = {
29
+ lite: 'Prefer the simplest correct solution.',
30
+ review: 'Review only for avoidable complexity; recommend the smallest correct replacement.',
31
+ };
32
+
33
+ function ponytailFallback(mode: string): string {
34
+ const intensity = PONYTAIL_FALLBACK_INTENSITY[mode] ?? 'Use the minimum correct solution. Delete or reuse before adding.';
35
+ return `🦥 PONYTAIL MODE ACTIVE — level: ${mode}\n${intensity} Understand the path first and fix root causes, not symptoms. Prefer the standard library and YAGNI. Avoid speculative abstractions and dependencies. Preserve correctness. Verify changed behavior.`;
36
+ }
37
+
38
+ function levelSummary(state: ComboState): string {
39
+ return `caveman=${state.caveman} rtk=${state.rtk} ponytail=${state.ponytail}`;
40
+ }
41
+
42
+ function loadPonytailInstructions(mode: string): string {
43
+ try {
44
+ const installed = path.join(
45
+ os.homedir(),
46
+ '.omp',
47
+ 'plugins',
48
+ 'node_modules',
49
+ '@dietrichgebert',
50
+ 'ponytail',
51
+ 'hooks',
52
+ 'ponytail-instructions.js'
53
+ );
54
+ const { getPonytailInstructions } = require(installed) as { getPonytailInstructions?: (mode: string) => string };
55
+ if (typeof getPonytailInstructions === 'function') return getPonytailInstructions(mode);
56
+ } catch { }
57
+ return ponytailFallback(mode);
58
+ }
59
+
60
+ export default function comboToggleExtension(pi: ExtensionApi): void {
61
+ pi.setLabel?.('Combo session toggle (all 3 add-ons)');
62
+
63
+ let lastCtx: ExtensionCtx | undefined = undefined;
64
+
65
+ function syncStatus(ctx?: ExtensionCtx): void {
66
+ lastCtx = paintableCtx(lastCtx, ctx);
67
+ const c = lastCtx;
68
+ if (!c?.ui?.setStatus) return;
69
+ // Single unified bar replaces the three per-extension bars while a preset is
70
+ // active. In custom/off, the individual bars come back and combo stays clear.
71
+ if (!isComboPresetActive()) {
72
+ c.ui.setStatus('combo', undefined);
73
+ return;
74
+ }
75
+ // Read the live bridge, not cached extension state: sibling extensions
76
+ // reconcile it from persisted entries before this one runs.
77
+ const state = getSharedComboState();
78
+ const theme = c.ui.theme;
79
+ const c0 = state.caveman.toUpperCase();
80
+ const r = state.rtk.toUpperCase();
81
+ const p = state.ponytail.toUpperCase();
82
+ const lvl = state.level.toUpperCase();
83
+ const label = `combo ${lvl}: 🪨caveman=${c0} ⚡rtk=${r} 🦥ponytail=${p}`;
84
+ c.ui.setStatus('combo', theme?.fg ? `${theme.fg('accent', '🧩')} ${theme.fg('muted', label)}` : `🧩 ${label}`);
85
+ // Clobber any sibling bars another extension may have painted in a race
86
+ // during session_start — our bar is canonical for the duration of the preset.
87
+ c.ui.setStatus('caveman', undefined);
88
+ c.ui.setStatus('rtk', undefined);
89
+ c.ui.setStatus('ponytail', undefined);
90
+ }
91
+
92
+ // ponytail: same persistence as /combo — siblings (incl. upstream ponytail)
93
+ // restore from these entries, so the fallback must write them too or the
94
+ // preset evaporates on resume and ponytail never activates.
95
+ function persistPreset(level: string): void {
96
+ const modes = COMBO_LEVELS[level];
97
+ pi.appendEntry?.('caveman-mode', { mode: modes.caveman });
98
+ pi.appendEntry?.('rtk-mode', { enabled: modes.rtk === 'on' });
99
+ pi.appendEntry?.('ponytail-mode', { mode: modes.ponytail });
100
+ pi.appendEntry?.('combo-level', { level });
101
+ }
102
+
103
+ function useState(state: Readonly<ComboState>, ctx?: ExtensionCtx): Readonly<ComboState> {
104
+ syncStatus(ctx);
105
+ return state;
106
+ }
107
+
108
+ function reconcile(ctx?: ExtensionCtx): Readonly<ComboState> {
109
+ if (!ctx?.hasUI) return getSharedComboState();
110
+ return useState(reconcileSharedComboEntries(sessionEntries(ctx)), ctx);
111
+ }
112
+ function listen(ctx?: ExtensionCtx): void {
113
+ // Stable identity: the bridge set dedupes, so repeated track()/command
114
+ // calls register once instead of stacking duplicate listeners.
115
+ if (ctx?.hasUI) setSharedComboListener(useState);
116
+ }
117
+
118
+ function track(ctx?: ExtensionCtx): void {
119
+ listen(ctx);
120
+ if (ctx?.hasUI) reconcile(ctx);
121
+ }
122
+
123
+ pi.registerCommand?.('combo', {
124
+ description: 'Toggle all 3 OMP add-ons at once. Usage: /combo <off|medium|balanced|max|status>',
125
+ handler: async (args, ctx) => {
126
+ listen(ctx);
127
+ const arg = String(args || '').trim().toLowerCase();
128
+
129
+ if (!arg || arg === 'status') {
130
+ const state = reconcile(ctx);
131
+ ctx?.ui?.notify?.(
132
+ `Combo: ${state.level === 'custom' ? 'INACTIVE' : state.level.toUpperCase()} (${levelSummary(state)})`,
133
+ 'info'
134
+ );
135
+ return;
136
+ }
137
+
138
+ if (arg === 'help') {
139
+ ctx?.ui?.notify?.(
140
+ '/combo off — disables all 3 (caveman, rtk, ponytail)\n' +
141
+ '/combo medium — light: caveman=lite, rtk=on, ponytail=lite\n' +
142
+ '/combo balanced — middle: caveman=full, rtk=on, ponytail=full\n' +
143
+ '/combo max — aggressive: caveman=ultra, rtk=on, ponytail=ultra',
144
+ 'info'
145
+ );
146
+ return;
147
+ }
148
+
149
+ const level = normalizeComboLevel(arg);
150
+ if (!level) {
151
+ ctx?.ui?.notify?.(
152
+ `Unknown combo level: ${arg}. Use: off | medium | balanced | max`,
153
+ 'warning'
154
+ );
155
+ return;
156
+ }
157
+
158
+ persistPreset(level);
159
+ useState(setSharedComboLevel(level), ctx);
160
+
161
+ const active = activeModesSummary(getSharedComboState());
162
+ ctx?.ui?.notify?.(
163
+ level === 'off' ? `Combo off — all tersio modes inactive for this session. Active: ${active}.` : `Combo ${level} on — ${active} active for this session.`,
164
+ 'info'
165
+ );
166
+ },
167
+ });
168
+
169
+ pi.on('session_start', async (_event, ctx) => {
170
+ track(ctx);
171
+ if (!ctx?.hasUI) syncStatus(ctx);
172
+ // Installer/user-configured default applies only when no persisted *mode*
173
+ // state exists — unrelated session entries must not block it, or the
174
+ // combo bar never paints on sessions that already carry other entries.
175
+ const entries = sessionEntries(ctx);
176
+ const hasModeState = entries.some((e) => e?.type === 'custom' && (
177
+ e.customType === 'combo-level' || e.customType === 'caveman-mode' ||
178
+ e.customType === 'rtk-mode' || e.customType === 'ponytail-mode'));
179
+ if (getSharedComboState().level === 'off' && !hasModeState) {
180
+ const fallback = readComboDefault();
181
+ if (fallback !== 'off') {
182
+ persistPreset(fallback);
183
+ useState(setSharedComboLevel(fallback), ctx);
184
+ ctx?.ui?.notify?.(`Combo default applied: ${fallback} — ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
185
+ }
186
+ }
187
+ // Standalone ponytail default: no sibling extension restores it (upstream
188
+ // owns the command), so apply it here when nothing persisted a
189
+ // ponytail-mode entry. Skipped when it matches the active preset — the
190
+ // preset entry already covers that, no redundant write.
191
+ if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
192
+ const ponytailFallback = readPonytailDefault();
193
+ if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
194
+ pi.appendEntry?.('ponytail-mode', { mode: ponytailFallback });
195
+ useState(setSharedComboMode('ponytail', ponytailFallback), ctx);
196
+ }
197
+ }
198
+ });
199
+
200
+ for (const event of ['session_branch', 'session_tree', 'agent_start']) {
201
+ pi.on(event, async (_event, ctx) => {
202
+ track(ctx);
203
+ });
204
+ }
205
+
206
+ pi.on<SystemPromptEvent>('before_agent_start', async (event, ctx) => {
207
+ if (ctx?.hasUI) reconcile(ctx);
208
+ if (!isOmpSubagentPrompt(event.systemPrompt)) return;
209
+
210
+ const mode = getSharedComboState().ponytail;
211
+ if (mode === 'off' || systemPromptIncludes(event.systemPrompt, 'PONYTAIL MODE ACTIVE')) return;
212
+ const base = asPromptArray(event.systemPrompt);
213
+ return { systemPrompt: [...base, loadPonytailInstructions(mode)] };
214
+ });
215
+
216
+ // Slash commands only; natural-language input caused accidental toggles and has no reload context.
217
+ }
@@ -0,0 +1,162 @@
1
+ // Shared helpers for the installer and the AI add-ons updater.
2
+ // Node built-ins only — this module must stay dependency-free.
3
+
4
+ import { createHash } from 'node:crypto';
5
+ import { createWriteStream } from 'node:fs';
6
+ import fs from 'node:fs/promises';
7
+ import https from 'node:https';
8
+ import path from 'node:path';
9
+
10
+ export interface HttpOptions {
11
+ maxRedirects?: number;
12
+ }
13
+
14
+ // Promise.withResolvers is Node 22+; the package supports Node 18+.
15
+ // ponytail: swap for the built-in when engines bumps to >=22.
16
+ export function withResolvers<T>(): PromiseWithResolvers<T> {
17
+ let resolve!: (value: T | PromiseLike<T>) => void;
18
+ let reject!: (reason?: unknown) => void;
19
+ const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
20
+ return { promise, resolve, reject };
21
+ }
22
+
23
+ export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
24
+ export const CAVEMAN_REMOTE_RULE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/rules/caveman-activate.md';
25
+
26
+ export interface RtkPlatformSpec {
27
+ triple: string;
28
+ ext: string;
29
+ binary: string;
30
+ }
31
+
32
+ export interface RtkReleaseAsset {
33
+ name: string;
34
+ browser_download_url: string;
35
+ }
36
+
37
+ export interface RtkRelease {
38
+ tag_name?: string;
39
+ assets?: RtkReleaseAsset[];
40
+ }
41
+
42
+ const RTK_PLATFORM_SPECS: Record<string, RtkPlatformSpec> = {
43
+ 'win32/x64': { triple: 'x86_64-pc-windows-msvc', ext: '.zip', binary: 'rtk.exe' },
44
+ 'linux/x64': { triple: 'x86_64-unknown-linux-musl', ext: '.tar.gz', binary: 'rtk' },
45
+ 'linux/arm64': { triple: 'aarch64-unknown-linux-gnu', ext: '.tar.gz', binary: 'rtk' },
46
+ 'darwin/x64': { triple: 'x86_64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
47
+ 'darwin/arm64': { triple: 'aarch64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
48
+ };
49
+
50
+ export function rtkPlatformSpec(platform: string = process.platform, arch: string = process.arch): RtkPlatformSpec | null {
51
+ return RTK_PLATFORM_SPECS[`${platform}/${arch}`] || null;
52
+ }
53
+
54
+ // Single source for the 3xx+location redirect rule shared by httpsGet/httpsDownload.
55
+ function redirectNext(res: { statusCode?: number; headers: { location?: string } }, url: string): string | null {
56
+ if (res.statusCode !== undefined && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
57
+ return new URL(res.headers.location, url).href;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function httpsGet(url: string, opts: HttpOptions = {}): Promise<string> {
63
+ const { promise, resolve, reject } = withResolvers<string>();
64
+ const maxRedirects = opts.maxRedirects ?? 5;
65
+ const req = https.get(url, { headers: { 'User-Agent': 'tersio', Accept: 'application/json,*/*' } }, (res) => {
66
+ const next = redirectNext(res, url);
67
+ if (next) {
68
+ if (maxRedirects <= 0) { res.resume(); reject(new Error(`Too many redirects fetching ${url}`)); return; }
69
+ res.resume();
70
+ resolve(httpsGet(next, { maxRedirects: maxRedirects - 1 }));
71
+ return;
72
+ }
73
+ if (res.statusCode !== 200) { res.resume(); reject(new Error(`HTTP ${res.statusCode} for ${url}`)); return; }
74
+ let body = '';
75
+ // ponytail: streamed accumulation — fine for tens of KB; stream-pipe if assets ever exceed a few MB.
76
+ res.setEncoding('utf8');
77
+ res.on('data', (chunk) => { body += chunk; });
78
+ res.on('end', () => resolve(body));
79
+ });
80
+ req.on('error', reject);
81
+ req.setTimeout(30000, () => req.destroy(new Error(`Timeout fetching ${url}`)));
82
+ return promise;
83
+ }
84
+
85
+ export function httpsDownload(url: string, dest: string, opts: HttpOptions = {}): Promise<void> {
86
+ const { promise, resolve, reject } = withResolvers<void>();
87
+ const maxRedirects = opts.maxRedirects ?? 5;
88
+ const req = https.get(url, { headers: { 'User-Agent': 'tersio', Accept: '*/*' } }, (res) => {
89
+ const next = redirectNext(res, url);
90
+ if (next) {
91
+ if (maxRedirects <= 0) { res.resume(); reject(new Error(`Too many redirects downloading ${url}`)); return; }
92
+ res.resume();
93
+ resolve(httpsDownload(next, dest, { maxRedirects: maxRedirects - 1 }));
94
+ return;
95
+ }
96
+ if (res.statusCode !== 200) { res.resume(); reject(new Error(`HTTP ${res.statusCode} for ${url}`)); return; }
97
+ const file = createWriteStream(dest);
98
+ res.pipe(file);
99
+ file.on('finish', () => file.close(() => resolve()));
100
+ file.on('error', (err) => {
101
+ req.destroy();
102
+ res.resume();
103
+ reject(err);
104
+ });
105
+ });
106
+ req.on('error', reject);
107
+ req.setTimeout(120000, () => req.destroy(new Error(`Timeout downloading ${url}`)));
108
+ return promise;
109
+ }
110
+
111
+ export async function fetchJson<T>(url: string): Promise<T> {
112
+ return JSON.parse(await httpsGet(url)) as T;
113
+ }
114
+
115
+ export function sha256Hex(text: string): string {
116
+ return createHash('sha256').update(text, 'utf8').digest('hex');
117
+ }
118
+
119
+ export async function sha256File(filePath: string): Promise<string> {
120
+ const buf = await fs.readFile(filePath);
121
+ return createHash('sha256').update(buf).digest('hex');
122
+ }
123
+
124
+ export function parseChecksum(checksumsText: string, assetName: string): string | null {
125
+ const target = path.basename(assetName);
126
+ for (const line of checksumsText.split(/\r?\n/)) {
127
+ const m = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
128
+ if (m && path.basename(m[2]) === target) return m[1].toLowerCase();
129
+ }
130
+ return null;
131
+ }
132
+
133
+ export async function findFile(dir: string, name: string): Promise<string | null> {
134
+ try {
135
+ const ents = await fs.readdir(dir, { withFileTypes: true });
136
+ for (const e of ents) {
137
+ const full = path.join(dir, e.name);
138
+ if (e.isDirectory()) {
139
+ const found = await findFile(full, name);
140
+ if (found) return found;
141
+ } else if (e.isFile()) {
142
+ if (e.name === name) return full;
143
+ // Match rtk-* asset names (e.g. rtk-x86_64-unknown-linux-musl).
144
+ if (name === 'rtk' || name === 'rtk.exe') {
145
+ const base = e.name.toLowerCase();
146
+ if (!/\.(txt|md|json|sha256|sig|asc|pem|crt|license)$/i.test(base)) {
147
+ if (base === 'rtk' || base === 'rtk.exe' || /^rtk[-_.]/.test(base)) return full;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ } catch { /* ignore */ }
153
+ return null;
154
+ }
155
+
156
+ export async function readTextIfExists(p: string): Promise<string | null> {
157
+ try { return await fs.readFile(p, 'utf8'); } catch { return null; }
158
+ }
159
+
160
+ export function normalizeRtkVersion(value: string | undefined): string {
161
+ return String(value || '').replace(/^rtk\s+/i, '').replace(/^v/i, '').trim();
162
+ }