@krtclcdy/tersio 2.25.3 → 2.25.4
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 +2 -2
- package/dist/cli/doctor-fix.js +1 -1
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/install.js +37 -8
- package/dist/cli/manifest.js +1 -0
- package/dist/extensions/ai-addons-updater/index.js +3 -3
- package/dist/extensions/caveman-session/index.js +10 -3
- package/dist/extensions/combo-toggle/index.js +2 -2
- package/dist/extensions/opencode/server.js +45 -1
- package/dist/extensions/rtk-filter/index.js +87 -0
- package/dist/extensions/rtk-session/index.js +3 -2
- package/dist/extensions/shared/host.js +18 -12
- package/dist/extensions/shared/session-state.js +3 -1
- package/extensions/ai-addons-updater/index.ts +2 -3
- package/extensions/caveman-session/index.ts +10 -3
- package/extensions/combo-toggle/index.ts +2 -2
- package/extensions/opencode/server.ts +38 -1
- package/extensions/rtk-filter/index.ts +90 -0
- package/extensions/rtk-session/index.ts +3 -2
- package/extensions/shared/host.ts +26 -13
- package/extensions/shared/session-state.ts +3 -1
- package/extensions/shared/types.ts +10 -2
- package/package.json +2 -1
- package/dist/cli/headroom-install.js +0 -266
- package/dist/cli/headroom.js +0 -492
- package/dist/extensions/headroom-session/index.js +0 -93
- package/dist/extensions/shared/headroom.js +0 -131
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// pi's built-in grep and glob never reach the bash hook; `read` is absent because rtk has no filter for it.
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { resolveRtkBinary } from '../lib/utils.ts';
|
|
6
|
+
import { readRtkDefault } from '../shared/plugin-settings.ts';
|
|
7
|
+
import { getSharedComboState, lastCustomValue, sessionEntries } from '../shared/session-state.ts';
|
|
8
|
+
import type { ExtensionApi, ExtensionCtx, SessionEntry, ToolResultEvent } from '../shared/types.ts';
|
|
9
|
+
|
|
10
|
+
/** Tool name to `rtk pipe --filter` name. Exported so the OpenCode host reuses one map. */
|
|
11
|
+
export const FILTER_BY_TOOL = new Map([
|
|
12
|
+
['grep', 'grep'],
|
|
13
|
+
['glob', 'find'],
|
|
14
|
+
['find', 'find'],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** Below this the rtk spawn costs more than the filter saves. */
|
|
18
|
+
export const MIN_FILTER_BYTES = 1200;
|
|
19
|
+
const FILTER_TIMEOUT_MS = 5000;
|
|
20
|
+
|
|
21
|
+
/** Absolute, so a minimal PATH in the host cannot break the spawn. */
|
|
22
|
+
const SHELL = '/bin/sh';
|
|
23
|
+
|
|
24
|
+
export type FilterExec = (
|
|
25
|
+
cmd: string,
|
|
26
|
+
args: string[],
|
|
27
|
+
opts?: { signal?: AbortSignal; cwd?: string; timeout?: number },
|
|
28
|
+
) => Promise<{ stdout: string; code: number }>;
|
|
29
|
+
|
|
30
|
+
function modeOf(value: unknown): boolean | null {
|
|
31
|
+
if (value === true) return true;
|
|
32
|
+
if (value === false) return false;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when rtk is on for the session: a persisted entry wins, then a live switch, then the configured default. */
|
|
37
|
+
export function rtkActive(entries: SessionEntry[] | null | undefined): boolean {
|
|
38
|
+
const persisted = lastCustomValue(entries, 'rtk-mode', (data) => modeOf(data?.enabled));
|
|
39
|
+
if (persisted !== null) return persisted;
|
|
40
|
+
return getSharedComboState().rtk === 'on' || readRtkDefault();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The text in a result's content parts, or null when the parts are not plain text. */
|
|
44
|
+
export function filterableText(parts: unknown): string | null {
|
|
45
|
+
if (!Array.isArray(parts) || parts.length === 0) return null;
|
|
46
|
+
// An image or a mixed result is not text to filter.
|
|
47
|
+
if (parts.some((part) => (part as { type?: unknown } | null)?.type !== 'text')) return null;
|
|
48
|
+
return parts.map((part) => (part as { text?: unknown } | null)?.text ?? '').join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Pipe text through one rtk filter; null on any failure, so the caller keeps its original. */
|
|
52
|
+
export async function filterThroughRtk(
|
|
53
|
+
exec: FilterExec,
|
|
54
|
+
request: { bin: string; filter: string; text: string; signal?: AbortSignal },
|
|
55
|
+
): Promise<string | null> {
|
|
56
|
+
const { bin, filter, text, signal } = request;
|
|
57
|
+
let dir: string | undefined;
|
|
58
|
+
try {
|
|
59
|
+
dir = mkdtempSync(join(tmpdir(), 'tersio-rtk-'));
|
|
60
|
+
const file = join(dir, 'in.txt');
|
|
61
|
+
writeFileSync(file, text, 'utf8');
|
|
62
|
+
// `rtk pipe` reads stdin only and execFile has no stdin, so one redirect.
|
|
63
|
+
const result = await exec(SHELL, ['-c', `"${bin}" pipe -f ${filter} < "${file}"`], { signal, timeout: FILTER_TIMEOUT_MS });
|
|
64
|
+
if (result.code !== 0) return null;
|
|
65
|
+
const out = result.stdout;
|
|
66
|
+
if (!out.trim() || out.length >= text.length) return null;
|
|
67
|
+
return out;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
} finally {
|
|
71
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default function rtkFilterExtension(pi: ExtensionApi): void {
|
|
76
|
+
const bin = resolveRtkBinary();
|
|
77
|
+
const exec = pi.exec;
|
|
78
|
+
if (!bin || !exec) return;
|
|
79
|
+
pi.on<ToolResultEvent>('tool_result', async (event, ctx: ExtensionCtx) => {
|
|
80
|
+
const filter = FILTER_BY_TOOL.get(String(event?.toolName ?? '').toLowerCase());
|
|
81
|
+
if (!filter || event?.isError) return;
|
|
82
|
+
const text = filterableText(event?.content);
|
|
83
|
+
if (text === null || text.length < MIN_FILTER_BYTES) return;
|
|
84
|
+
if (!rtkActive(sessionEntries(ctx))) return;
|
|
85
|
+
const filtered = await filterThroughRtk(exec, { bin, filter, text, signal: ctx?.signal });
|
|
86
|
+
if (!filtered) return;
|
|
87
|
+
// Built-in grep and glob declare no output schema, so no structuredContent is lost.
|
|
88
|
+
return { content: [{ type: 'text', text: filtered }] };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -23,7 +23,8 @@ function setRtkProcessEnabled(enabled: boolean): void {
|
|
|
23
23
|
else process.env.RTK_DISABLED = '1';
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// rtk's awareness contract: state the output contract, never tell the model to type `rtk`.
|
|
27
|
+
const RTK_PROMPT = `RTK guidance active. Bash output, and large grep and glob results, are condensed to save tokens: every signal kept, costly noise dropped. Treat a condensed result as complete. Re-run something unfiltered only when its result is empty, garbled, or contradicts its exit code.`;
|
|
27
28
|
|
|
28
29
|
export default function rtkSessionExtension(pi: ExtensionApi): void {
|
|
29
30
|
let enabled = DEFAULT_ENABLED;
|
|
@@ -134,6 +135,6 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
|
|
|
134
135
|
if (!active) return;
|
|
135
136
|
// Skip when already present, so a host that re-presents the prompt cannot stack it.
|
|
136
137
|
if (systemPromptIncludes(event.systemPrompt, RTK_PROMPT)) return;
|
|
137
|
-
return injectPromptText(pi, event, RTK_PROMPT);
|
|
138
|
+
return injectPromptText(pi, event, { text: RTK_PROMPT, sectionTag: 'rtk' });
|
|
138
139
|
});
|
|
139
140
|
}
|
|
@@ -36,7 +36,7 @@ interface HostAdapter {
|
|
|
36
36
|
label(pi: HostHandle, text: string): void;
|
|
37
37
|
on<E>(pi: ExtensionApi, event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;
|
|
38
38
|
select(ui: UiApi | undefined, title: string, options: SelectOption[], dialogOptions?: Record<string, unknown>): Promise<string | undefined>;
|
|
39
|
-
inject(event: SystemPromptEvent,
|
|
39
|
+
inject(event: SystemPromptEvent, block: PromptBlock): PromptInjection | undefined;
|
|
40
40
|
stringArray(pi: ExtensionApi, name: string, spec: StringArraySpec): ToolParams;
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -53,15 +53,21 @@ const piAdapter: HostAdapter = {
|
|
|
53
53
|
if (picked === undefined) return undefined;
|
|
54
54
|
return options[shown.indexOf(picked)]?.label ?? picked;
|
|
55
55
|
},
|
|
56
|
-
inject(event,
|
|
57
|
-
const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
56
|
+
inject(event, block) {
|
|
57
|
+
const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
58
58
|
const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
|
|
59
59
|
const options = event.systemPromptOptions;
|
|
60
|
+
// A named section is replaced in place; returning systemPrompt replaces the whole prompt.
|
|
61
|
+
const sections = options?.sections;
|
|
62
|
+
if (block.sectionTag && sections && typeof sections === 'object') {
|
|
63
|
+
sections[block.sectionTag] = block.text;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
60
66
|
if (options && typeof options.appendSystemPrompt === 'string') {
|
|
61
|
-
options.appendSystemPrompt = dropStale([options.appendSystemPrompt]).concat(text).filter(Boolean).join('\n\n');
|
|
67
|
+
options.appendSystemPrompt = dropStale([options.appendSystemPrompt]).concat(block.text).filter(Boolean).join('\n\n');
|
|
62
68
|
return undefined;
|
|
63
69
|
}
|
|
64
|
-
return { systemPrompt: `${dropStale(asPromptArray(event.systemPrompt)).join('\n\n')}\n\n${text}` };
|
|
70
|
+
return { systemPrompt: `${dropStale(asPromptArray(event.systemPrompt)).join('\n\n')}\n\n${block.text}` };
|
|
65
71
|
},
|
|
66
72
|
stringArray(_pi, name, spec) {
|
|
67
73
|
const array: Record<string, unknown> = { type: 'array', items: { type: 'string' } };
|
|
@@ -78,10 +84,10 @@ const ompAdapter: HostAdapter = {
|
|
|
78
84
|
if (!ui?.select) return undefined;
|
|
79
85
|
return ui.select(title, options, dialogOptions);
|
|
80
86
|
},
|
|
81
|
-
inject(event,
|
|
82
|
-
const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
87
|
+
inject(event, block) {
|
|
88
|
+
const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
83
89
|
const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
|
|
84
|
-
return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), text] };
|
|
90
|
+
return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), block.text] };
|
|
85
91
|
},
|
|
86
92
|
stringArray(pi, name, spec) {
|
|
87
93
|
const { z } = pi.zod as { z: ZodFactory['z'] };
|
|
@@ -98,10 +104,10 @@ const opencodeAdapter: HostAdapter = {
|
|
|
98
104
|
if (!ui?.select) return undefined;
|
|
99
105
|
return ui.select(title, options, dialogOptions);
|
|
100
106
|
},
|
|
101
|
-
inject(event,
|
|
102
|
-
const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
107
|
+
inject(event, block) {
|
|
108
|
+
const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
103
109
|
const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
|
|
104
|
-
return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), text] };
|
|
110
|
+
return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), block.text] };
|
|
105
111
|
},
|
|
106
112
|
stringArray(_pi, name, spec) {
|
|
107
113
|
const array: Record<string, unknown> = { type: 'array', items: { type: 'string' } };
|
|
@@ -141,8 +147,15 @@ export async function hostSelect(
|
|
|
141
147
|
return adapterFor(pi).select(ui, title, options, dialogOptions);
|
|
142
148
|
}
|
|
143
149
|
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
/** One prompt block: its text, the section tag that owns it, and prior text to drop. */
|
|
151
|
+
export interface PromptBlock {
|
|
152
|
+
text: string;
|
|
153
|
+
sectionTag?: string;
|
|
154
|
+
staleMarker?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function injectPromptText(pi: HostHandle, event: SystemPromptEvent, block: PromptBlock): PromptInjection | undefined {
|
|
158
|
+
return adapterFor(pi).inject(event, block);
|
|
146
159
|
}
|
|
147
160
|
|
|
148
161
|
export function stringArrayToolParams(pi: ExtensionApi, name: string, spec: StringArraySpec = {}): ToolParams {
|
|
@@ -95,7 +95,9 @@ export function normalizeInputCommand(value: unknown): string {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
export function asPromptArray(systemPrompt: string | string[]): string[] {
|
|
98
|
-
|
|
98
|
+
const parts = Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
|
|
99
|
+
// OMP can hand back a hole in the array; a non-string breaks every marker check.
|
|
100
|
+
return parts.filter((part): part is string => typeof part === 'string');
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
export function systemPromptIncludes(systemPrompt: string | string[], marker: string): boolean {
|
|
@@ -36,6 +36,7 @@ export interface UiApi {
|
|
|
36
36
|
export interface ExtensionCtx {
|
|
37
37
|
hasUI?: boolean;
|
|
38
38
|
cwd?: string;
|
|
39
|
+
signal?: AbortSignal;
|
|
39
40
|
sessionManager?: {
|
|
40
41
|
getBranch?: () => SessionEntry[];
|
|
41
42
|
getEntries?: () => SessionEntry[];
|
|
@@ -46,13 +47,20 @@ export interface ExtensionCtx {
|
|
|
46
47
|
|
|
47
48
|
export interface SystemPromptEvent {
|
|
48
49
|
systemPrompt: string | string[];
|
|
49
|
-
// pi only: the mutable prompt sections. OMP does not provide
|
|
50
|
-
systemPromptOptions?: { appendSystemPrompt?: string };
|
|
50
|
+
// pi only: the mutable prompt sections and append slot. OMP does not provide them.
|
|
51
|
+
systemPromptOptions?: { appendSystemPrompt?: string; sections?: Record<string, string> };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/** What a `before_agent_start` handler hands back. OMP takes an array, pi a string. */
|
|
54
55
|
export type PromptInjection = { systemPrompt: string[] } | { systemPrompt: string };
|
|
55
56
|
|
|
57
|
+
/** A finished tool call, before its result reaches the model. pi and OMP provide it. */
|
|
58
|
+
export interface ToolResultEvent {
|
|
59
|
+
toolName?: string;
|
|
60
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
61
|
+
isError?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
/** A tool `parameters` object. OMP wants zod, pi wants JSON Schema. */
|
|
57
65
|
export type ToolParams = Record<string, unknown>;
|
|
58
66
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krtclcdy/tersio",
|
|
3
|
-
"version": "2.25.
|
|
3
|
+
"version": "2.25.4",
|
|
4
4
|
"description": "✂️ Token saver and usage tracker for coding agents: terse Caveman replies, compact RTK shell output, lean Ponytail code calls, and a gain dashboard — one-command combo presets over one shared extension tree.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"token-saving",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"extensions": [
|
|
33
33
|
"./extensions/caveman-session/index.ts",
|
|
34
34
|
"./extensions/rtk-session/index.ts",
|
|
35
|
+
"./extensions/rtk-filter/index.ts",
|
|
35
36
|
"./extensions/combo-toggle/index.ts",
|
|
36
37
|
"./extensions/tersio-commands/index.ts"
|
|
37
38
|
],
|
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
// The Headroom bundle: Tersio installs, updates, and removes the add-on so the user never runs a Python tool by hand.
|
|
2
|
-
// A Tersio-owned venv at ~/.tersio/headroom stays inside our home, so uninstall is one directory removal.
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import { promises as fs } from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { execP } from './common.js';
|
|
7
|
-
import { sayTagged } from './interactive.js';
|
|
8
|
-
import { findFile, headroomBundleDir, headroomBundledBinary, httpsDownload, httpsGet, parseChecksum, sha256File, } from '../extensions/lib/utils.js';
|
|
9
|
-
import { HEADROOM_TESTED_VERSION, parseHeadroomVersion } from '../extensions/shared/headroom.js';
|
|
10
|
-
/** The extra that carries the proxy. No `[ml]`: torch plus model weights are opt-in, later. */
|
|
11
|
-
const HEADROOM_SPEC = 'headroom-ai[proxy]';
|
|
12
|
-
/** uv resolves and, when needed, downloads a managed CPython for this request. */
|
|
13
|
-
const PYTHON_REQUEST = '>=3.10';
|
|
14
|
-
const UV_RELEASE_BASE = 'https://github.com/astral-sh/uv/releases/latest/download';
|
|
15
|
-
const IS_WINDOWS = process.platform === 'win32';
|
|
16
|
-
/** CI and tests set this so an install never reaches the network. Not a user-facing option. */
|
|
17
|
-
function bundleDisabled() {
|
|
18
|
-
return process.env.TERSIO_SKIP_HEADROOM_BUNDLE === '1';
|
|
19
|
-
}
|
|
20
|
-
function skippedResult() {
|
|
21
|
-
sayTagged(' [skip] Headroom bundle skipped (TERSIO_SKIP_HEADROOM_BUNDLE=1)');
|
|
22
|
-
return { ok: true, bin: null, version: null, detail: 'skipped', skipped: true };
|
|
23
|
-
}
|
|
24
|
-
// --- layout ----------------------------------------------------------------------------
|
|
25
|
-
export function headroomVenvDir() {
|
|
26
|
-
return path.join(headroomBundleDir(), 'venv');
|
|
27
|
-
}
|
|
28
|
-
function headroomVenvPython() {
|
|
29
|
-
return IS_WINDOWS
|
|
30
|
-
? path.join(headroomVenvDir(), 'Scripts', 'python.exe')
|
|
31
|
-
: path.join(headroomVenvDir(), 'bin', 'python');
|
|
32
|
-
}
|
|
33
|
-
/** The bundled entrypoint. `resolveHeadroomBinary` prefers it, so Tersio updates what Tersio installed. */
|
|
34
|
-
export const headroomBundledBin = headroomBundledBinary;
|
|
35
|
-
function uvManagedDir() {
|
|
36
|
-
return path.join(headroomBundleDir(), 'bin');
|
|
37
|
-
}
|
|
38
|
-
function uvManagedFile() {
|
|
39
|
-
return path.join(uvManagedDir(), IS_WINDOWS ? 'uv.exe' : 'uv');
|
|
40
|
-
}
|
|
41
|
-
/** Written only when Tersio downloaded uv itself, so uninstall knows what it may delete. */
|
|
42
|
-
function uvOwnedMarker() {
|
|
43
|
-
return path.join(headroomBundleDir(), 'uv-managed');
|
|
44
|
-
}
|
|
45
|
-
export function headroomBundleInstalled() {
|
|
46
|
-
return existsSync(headroomBundledBin());
|
|
47
|
-
}
|
|
48
|
-
const UV_PLATFORM_SPECS = {
|
|
49
|
-
'win32/x64': { triple: 'x86_64-pc-windows-msvc', ext: '.zip' },
|
|
50
|
-
'linux/x64': { triple: 'x86_64-unknown-linux-gnu', ext: '.tar.gz' },
|
|
51
|
-
'linux/arm64': { triple: 'aarch64-unknown-linux-gnu', ext: '.tar.gz' },
|
|
52
|
-
'darwin/x64': { triple: 'x86_64-apple-darwin', ext: '.tar.gz' },
|
|
53
|
-
'darwin/arm64': { triple: 'aarch64-apple-darwin', ext: '.tar.gz' },
|
|
54
|
-
};
|
|
55
|
-
export function uvPlatformSpec(platform = process.platform, arch = process.arch) {
|
|
56
|
-
return UV_PLATFORM_SPECS[`${platform}/${arch}`] ?? null;
|
|
57
|
-
}
|
|
58
|
-
async function onPath(name) {
|
|
59
|
-
try {
|
|
60
|
-
if (IS_WINDOWS) {
|
|
61
|
-
const found = await execP('where', [name], { timeout: 5000 });
|
|
62
|
-
return found.stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? null;
|
|
63
|
-
}
|
|
64
|
-
const found = await execP('sh', ['-c', `command -v ${name}`], { timeout: 5000 });
|
|
65
|
-
return found.stdout.trim() || null;
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/** A usable uv: the Tersio copy first (so tests and CI are deterministic), then PATH. */
|
|
72
|
-
export async function resolveUv() {
|
|
73
|
-
if (existsSync(uvManagedFile()))
|
|
74
|
-
return uvManagedFile();
|
|
75
|
-
return onPath('uv');
|
|
76
|
-
}
|
|
77
|
-
async function extractArchive(archivePath, extractDir) {
|
|
78
|
-
await fs.mkdir(extractDir, { recursive: true });
|
|
79
|
-
let attempts;
|
|
80
|
-
if (archivePath.endsWith('.zip')) {
|
|
81
|
-
attempts = IS_WINDOWS
|
|
82
|
-
? [['powershell', ['Expand-Archive', '-Path', archivePath, '-DestinationPath', extractDir, '-Force']]]
|
|
83
|
-
: [['unzip', ['-o', archivePath, '-d', extractDir]]];
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
attempts = [['tar', ['xzf', archivePath, '-C', extractDir]]];
|
|
87
|
-
}
|
|
88
|
-
for (const [cmd, args] of attempts) {
|
|
89
|
-
try {
|
|
90
|
-
await execP(cmd, args, { timeout: 60000 });
|
|
91
|
-
return true;
|
|
92
|
-
}
|
|
93
|
-
catch { /* try the next extractor */ }
|
|
94
|
-
}
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
/** Downloads uv into the Tersio home, checksum-verified, when the machine has none. */
|
|
98
|
-
async function installUv(options) {
|
|
99
|
-
const spec = uvPlatformSpec();
|
|
100
|
-
if (!spec) {
|
|
101
|
-
sayTagged(` [fail] No uv build for ${process.platform}/${process.arch}.`);
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
if (options.dryRun) {
|
|
105
|
-
sayTagged(` [dry-run] would download uv-${spec.triple}${spec.ext} into ${uvManagedDir()}`);
|
|
106
|
-
return uvManagedFile();
|
|
107
|
-
}
|
|
108
|
-
const asset = `uv-${spec.triple}${spec.ext}`;
|
|
109
|
-
// The parent must exist before mkdtemp runs inside it.
|
|
110
|
-
await fs.mkdir(headroomBundleDir(), { recursive: true });
|
|
111
|
-
const tmpDir = await fs.mkdtemp(path.join(headroomBundleDir(), 'tmp-'));
|
|
112
|
-
try {
|
|
113
|
-
const archivePath = path.join(tmpDir, asset);
|
|
114
|
-
sayTagged(` [get] ${asset}`);
|
|
115
|
-
await httpsDownload(`${UV_RELEASE_BASE}/${asset}`, archivePath, { maxRedirects: 5 });
|
|
116
|
-
// Fails closed: an unverified executable would run with the user's privileges.
|
|
117
|
-
const checksums = await httpsGet(`${UV_RELEASE_BASE}/${asset}.sha256`).catch(() => null);
|
|
118
|
-
const expected = checksums ? parseChecksum(checksums, asset) : null;
|
|
119
|
-
if (!expected) {
|
|
120
|
-
sayTagged(` [fail] No checksum published for ${asset} — refusing to install uv.`);
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
if (await sha256File(archivePath) !== expected) {
|
|
124
|
-
sayTagged(` [fail] Checksum mismatch for ${asset} — refusing to install uv.`);
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
const extractDir = path.join(tmpDir, 'extracted');
|
|
128
|
-
if (!await extractArchive(archivePath, extractDir)) {
|
|
129
|
-
sayTagged(` [fail] Could not extract ${asset}`);
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
const found = await findFile(extractDir, IS_WINDOWS ? 'uv.exe' : 'uv');
|
|
133
|
-
if (!found) {
|
|
134
|
-
sayTagged(` [fail] Could not find uv in ${asset}`);
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
await fs.mkdir(uvManagedDir(), { recursive: true });
|
|
138
|
-
await fs.copyFile(found, uvManagedFile());
|
|
139
|
-
await fs.chmod(uvManagedFile(), 0o755).catch(() => { });
|
|
140
|
-
await fs.writeFile(uvOwnedMarker(), `${new Date().toISOString()}\n`, 'utf8');
|
|
141
|
-
return uvManagedFile();
|
|
142
|
-
}
|
|
143
|
-
catch (e) {
|
|
144
|
-
sayTagged(` [fail] Could not install uv: ${e.message}`);
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
finally {
|
|
148
|
-
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
async function ensureUv(options) {
|
|
152
|
-
const existing = await resolveUv();
|
|
153
|
-
if (existing)
|
|
154
|
-
return existing;
|
|
155
|
-
if (!options.quiet)
|
|
156
|
-
sayTagged(' [info] No uv found — Tersio installs its own copy under ~/.tersio/headroom');
|
|
157
|
-
return installUv(options);
|
|
158
|
-
}
|
|
159
|
-
// --- install / update ------------------------------------------------------------------
|
|
160
|
-
async function runUv(uv, args, options) {
|
|
161
|
-
if (options.dryRun) {
|
|
162
|
-
sayTagged(` [dry-run] would run: uv ${args.join(' ')}`);
|
|
163
|
-
return true;
|
|
164
|
-
}
|
|
165
|
-
if (options.verbose || !options.quiet)
|
|
166
|
-
sayTagged(` [run] uv ${args.join(' ')}`);
|
|
167
|
-
try {
|
|
168
|
-
const execOptions = { timeout: options.timeout ?? 600000 };
|
|
169
|
-
const result = await execP(uv, args, execOptions);
|
|
170
|
-
if (options.verbose && result.stdout.trim())
|
|
171
|
-
sayTagged(` ${result.stdout.trim().slice(-400)}`);
|
|
172
|
-
return true;
|
|
173
|
-
}
|
|
174
|
-
catch (e) {
|
|
175
|
-
const err = e;
|
|
176
|
-
sayTagged(` [fail] uv ${args[0]}: ${(err.stderr || err.message || '').trim().slice(0, 300)}`);
|
|
177
|
-
return false;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
/** Builds the venv if it is missing, then installs or upgrades the package inside it. */
|
|
181
|
-
async function provision(options, upgrade) {
|
|
182
|
-
const uv = await ensureUv(options);
|
|
183
|
-
if (!uv)
|
|
184
|
-
return { ok: false, bin: null, version: null, detail: 'uv unavailable' };
|
|
185
|
-
if (!existsSync(headroomVenvPython())) {
|
|
186
|
-
// uv fetches a managed CPython when the machine has no interpreter in range.
|
|
187
|
-
if (!await runUv(uv, ['venv', headroomVenvDir(), '--python', PYTHON_REQUEST], { ...options, timeout: 900000 })) {
|
|
188
|
-
return { ok: false, bin: null, version: null, detail: 'venv creation failed' };
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
const installArgs = ['pip', 'install', '--python', headroomVenvPython()];
|
|
192
|
-
if (upgrade)
|
|
193
|
-
installArgs.push('--upgrade');
|
|
194
|
-
installArgs.push(HEADROOM_SPEC);
|
|
195
|
-
if (!await runUv(uv, installArgs, options)) {
|
|
196
|
-
return { ok: false, bin: null, version: null, detail: `could not install ${HEADROOM_SPEC}` };
|
|
197
|
-
}
|
|
198
|
-
if (options.dryRun)
|
|
199
|
-
return { ok: true, bin: headroomBundledBin(), version: null, detail: 'dry run' };
|
|
200
|
-
const version = await bundledVersion();
|
|
201
|
-
if (!existsSync(headroomBundledBin())) {
|
|
202
|
-
return { ok: false, bin: null, version, detail: 'the package installed, but no headroom entrypoint appeared' };
|
|
203
|
-
}
|
|
204
|
-
return { ok: true, bin: headroomBundledBin(), version, detail: upgrade ? 'updated' : 'installed' };
|
|
205
|
-
}
|
|
206
|
-
async function bundledVersion() {
|
|
207
|
-
try {
|
|
208
|
-
const result = await execP(headroomBundledBin(), ['--version'], { timeout: 30000 });
|
|
209
|
-
return parseHeadroomVersion(result.stdout || result.stderr);
|
|
210
|
-
}
|
|
211
|
-
catch (e) {
|
|
212
|
-
const err = e;
|
|
213
|
-
return parseHeadroomVersion(err.stdout || err.stderr);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
/** Install the bundled Headroom. Idempotent: an existing venv is reused. */
|
|
217
|
-
export async function installHeadroom(options = {}) {
|
|
218
|
-
if (bundleDisabled())
|
|
219
|
-
return skippedResult();
|
|
220
|
-
if (headroomBundleInstalled() && !options.dryRun) {
|
|
221
|
-
const version = await bundledVersion();
|
|
222
|
-
return { ok: true, bin: headroomBundledBin(), version, detail: 'already installed' };
|
|
223
|
-
}
|
|
224
|
-
return provision(options, false);
|
|
225
|
-
}
|
|
226
|
-
/** Refresh the bundled Headroom to the newest release. */
|
|
227
|
-
export async function updateHeadroom(options = {}) {
|
|
228
|
-
if (bundleDisabled())
|
|
229
|
-
return skippedResult();
|
|
230
|
-
if (!headroomBundleInstalled() && !options.dryRun && !existsSync(headroomVenvPython())) {
|
|
231
|
-
return installHeadroom(options);
|
|
232
|
-
}
|
|
233
|
-
const result = await provision(options, true);
|
|
234
|
-
if (result.ok && result.version) {
|
|
235
|
-
const beyond = parseHeadroomVersion(result.version);
|
|
236
|
-
if (beyond && beyond !== HEADROOM_TESTED_VERSION && beyond > HEADROOM_TESTED_VERSION) {
|
|
237
|
-
sayTagged(` [warn] Headroom ${beyond} is newer than the tested ${HEADROOM_TESTED_VERSION}`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return result;
|
|
241
|
-
}
|
|
242
|
-
// --- remove ----------------------------------------------------------------------------
|
|
243
|
-
/** Removes the venv and anything else the bundle created, including a uv copy Tersio downloaded. */
|
|
244
|
-
export async function removeHeadroomBundle(options = {}) {
|
|
245
|
-
const root = headroomBundleDir();
|
|
246
|
-
if (!existsSync(root))
|
|
247
|
-
return true;
|
|
248
|
-
// Removal still happens under the skip flag: an uninstall must not leave the bundle behind.
|
|
249
|
-
if (options.dryRun) {
|
|
250
|
-
sayTagged(` [dry-run] would remove ${root}`);
|
|
251
|
-
return true;
|
|
252
|
-
}
|
|
253
|
-
try {
|
|
254
|
-
await fs.rm(root, { recursive: true, force: true });
|
|
255
|
-
sayTagged(` [rm] ${root}`);
|
|
256
|
-
return true;
|
|
257
|
-
}
|
|
258
|
-
catch (e) {
|
|
259
|
-
sayTagged(` [fail] could not remove ${root}: ${e.message}`);
|
|
260
|
-
return false;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
/** True when the bundle holds a uv copy Tersio downloaded (as opposed to one the user had). */
|
|
264
|
-
export function headroomOwnsUv() {
|
|
265
|
-
return existsSync(uvOwnedMarker());
|
|
266
|
-
}
|