@krtclcdy/tersio 2.25.2 → 2.25.3
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 +1 -1
- package/dashboard/dist/index.html +1 -1
- package/{cli → dist/cli}/common.js +4 -3
- package/{cli → dist/cli}/dashboard.js +30 -13
- package/{cli → dist/cli}/doctor-fix.js +1 -3
- package/{cli → dist/cli}/doctor.js +12 -5
- package/dist/cli/headroom-install.js +266 -0
- package/dist/cli/headroom.js +492 -0
- package/{cli → dist/cli}/install.js +19 -7
- package/{cli → dist/cli}/manifest.js +3 -2
- package/{cli → dist/cli}/settings.js +5 -23
- package/{cli → dist/cli}/uninstall.js +1 -1
- package/{cli → dist/cli}/update.js +4 -5
- package/{extensions → dist/extensions}/ai-addons-updater/index.js +2 -2
- package/{extensions → dist/extensions}/caveman-session/index.js +2 -2
- package/dist/extensions/caveman-session/rule-ultra.md +63 -0
- package/dist/extensions/caveman-session/rule.md +79 -0
- package/{extensions → dist/extensions}/combo-toggle/index.js +37 -27
- package/dist/extensions/headroom-session/index.js +93 -0
- package/{extensions → dist/extensions}/lib/utils.js +26 -1
- package/dist/extensions/shared/headroom.js +131 -0
- package/{extensions → dist/extensions}/shared/plugin-settings.js +1 -1
- package/{extensions → dist/extensions}/shared/session-state.js +14 -2
- package/{extensions → dist/extensions}/shared/status.js +7 -1
- package/{tersio.js → dist/tersio.js} +1 -1
- package/extensions/ai-addons-updater/index.ts +2 -2
- package/extensions/caveman-session/index.ts +2 -2
- package/extensions/caveman-session/rule-megacave.md +63 -0
- package/extensions/caveman-session/rule.md +4 -2
- package/extensions/combo-toggle/index.ts +35 -27
- package/extensions/lib/utils.ts +22 -1
- package/extensions/shared/plugin-settings.ts +1 -1
- package/extensions/shared/session-state.ts +15 -2
- package/extensions/shared/status.ts +8 -1
- package/package.json +15 -15
- package/extensions/opencode/index.js +0 -104
- /package/{cli → dist/cli}/banner.js +0 -0
- /package/{cli → dist/cli}/currency.js +0 -0
- /package/{cli → dist/cli}/hosts.js +0 -0
- /package/{cli → dist/cli}/interactive.js +0 -0
- /package/{cli → dist/cli}/profile.js +0 -0
- /package/{cli → dist/cli}/reset.js +0 -0
- /package/{cli → dist/cli}/rtk-wiring.js +0 -0
- /package/{cli → dist/cli}/usage.js +0 -0
- /package/{extensions/caveman-session/rule-wenyan.md → dist/extensions/caveman-session/rule-megacave.md} +0 -0
- /package/{extensions → dist/extensions}/opencode/server.js +0 -0
- /package/{extensions → dist/extensions}/rtk-session/index.js +0 -0
- /package/{extensions → dist/extensions}/shared/carbon.js +0 -0
- /package/{extensions → dist/extensions}/shared/host.js +0 -0
- /package/{extensions → dist/extensions}/shared/pricing.js +0 -0
- /package/{extensions → dist/extensions}/shared/rtk-gain.js +0 -0
- /package/{extensions → dist/extensions}/shared/types.js +0 -0
- /package/{extensions → dist/extensions}/shared/usage-ledger.js +0 -0
- /package/{extensions → dist/extensions}/shared/usage-store.js +0 -0
- /package/{extensions → dist/extensions}/tersio-commands/index.js +0 -0
|
@@ -4,7 +4,8 @@ import { promises as fs } from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
|
-
import {
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { findPackageRoot, readTextIfExists } from '../extensions/lib/utils.js';
|
|
8
9
|
import { parseCurrencyFlag, readStoredCurrency } from './currency.js';
|
|
9
10
|
const IS_WINDOWS = process.platform === 'win32';
|
|
10
11
|
const RTK_BINARY_NAME = IS_WINDOWS ? 'rtk.exe' : 'rtk';
|
|
@@ -15,8 +16,8 @@ const BUN_BIN_DIR = path.join(HOME, '.bun', 'bin');
|
|
|
15
16
|
const OMP_BIN = IS_WINDOWS ? 'omp.cmd' : 'omp';
|
|
16
17
|
const PACKAGE_NAME = '@krtclcdy/tersio';
|
|
17
18
|
const PACKAGE_BIN = 'tersio';
|
|
18
|
-
const { version: PACKAGE_VERSION } = createRequire(import.meta.url)('
|
|
19
|
-
const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', '
|
|
19
|
+
const { version: PACKAGE_VERSION } = createRequire(import.meta.url)(path.join(findPackageRoot(path.dirname(fileURLToPath(import.meta.url))), 'package.json'));
|
|
20
|
+
const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'megacave-lite', 'megacave-full', 'megacave-ultra']);
|
|
20
21
|
const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra']);
|
|
21
22
|
const RTK_DEFAULTS = new Set(['on', 'off']);
|
|
22
23
|
const DIAG_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
|
|
@@ -16,8 +16,8 @@ import { BACKUP_SCHEDULES, formatCliStatus, storedProfile, writePluginSettings }
|
|
|
16
16
|
import { normalizeComboLevel } from '../extensions/shared/session-state.js';
|
|
17
17
|
import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS } from './common.js';
|
|
18
18
|
import { PACKAGE_NAME } from './common.js';
|
|
19
|
-
import { resolveRtkBinary } from '../extensions/lib/utils.js';
|
|
20
|
-
const DASHBOARD_DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '
|
|
19
|
+
import { findPackageRoot, resolveRtkBinary } from '../extensions/lib/utils.js';
|
|
20
|
+
const DASHBOARD_DIST = path.join(findPackageRoot(path.dirname(fileURLToPath(import.meta.url))), 'dashboard', 'dist');
|
|
21
21
|
const DASHBOARD_INDEX = path.join(DASHBOARD_DIST, 'index.html');
|
|
22
22
|
const DASHBOARD_BRAND = path.join(DASHBOARD_DIST, 'brand.webp');
|
|
23
23
|
function requireDashboardBundle() {
|
|
@@ -634,17 +634,34 @@ async function runDashboard(options) {
|
|
|
634
634
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
635
635
|
res.end(await readSegment(DASHBOARD_INDEX));
|
|
636
636
|
});
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
637
|
+
await new Promise((resolve, reject) => {
|
|
638
|
+
let stopping = false;
|
|
639
|
+
const rejectOnce = (err) => {
|
|
640
|
+
if (!stopping)
|
|
641
|
+
reject(err);
|
|
642
|
+
};
|
|
643
|
+
const stop = () => {
|
|
644
|
+
if (stopping)
|
|
645
|
+
return;
|
|
646
|
+
stopping = true;
|
|
647
|
+
server.close(() => resolve());
|
|
648
|
+
server.closeIdleConnections();
|
|
649
|
+
};
|
|
650
|
+
process.once('SIGINT', stop);
|
|
651
|
+
process.once('SIGTERM', stop);
|
|
652
|
+
server.once('error', rejectOnce);
|
|
653
|
+
server.listen(options.port, '127.0.0.1', () => {
|
|
654
|
+
try {
|
|
655
|
+
process.title = 'tersio dashboard';
|
|
656
|
+
}
|
|
657
|
+
catch { /* non-POSIX shells keep node */ }
|
|
658
|
+
const address = server.address();
|
|
659
|
+
const port = typeof address === 'object' && address ? address.port : options.port;
|
|
660
|
+
const url = `http://127.0.0.1:${port}`;
|
|
661
|
+
console.log(`[ok] Dashboard live → ${url} (Ctrl-C to stop)`);
|
|
662
|
+
if (options.open)
|
|
663
|
+
openBrowser(url);
|
|
664
|
+
});
|
|
648
665
|
});
|
|
649
666
|
}
|
|
650
667
|
export { runDashboard, saveDashboardCurrency, readDiagSchedule, setDiagSchedule };
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { existsSync, promises as fs } from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
4
|
import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, removeExtensionFromConfig, dryRun, verbose, debug, execP, readPluginsPackage, writeIfChanged, } from './common.js';
|
|
6
5
|
import { execNetwork, sayTagged } from './interactive.js';
|
|
7
6
|
import { wireRtkOmp, wireRtkOpencode, wireRtkPi, ensureRtkInConfig } from './rtk-wiring.js';
|
|
8
7
|
import { RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
|
|
9
8
|
import { runLatestUpdate } from './update.js';
|
|
10
|
-
import { OPENCODE_SERVER_SHIM, TREE_FILES, sourcePath } from './manifest.js';
|
|
9
|
+
import { EXT_DIR, OPENCODE_SERVER_SHIM, TREE_FILES, sourcePath } from './manifest.js';
|
|
11
10
|
import { detectHosts, hostExtensionsDir } from './hosts.js';
|
|
12
|
-
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
|
|
13
11
|
// A written tree is the install for both hosts now, so --fix restores it for whichever hosts have it, alongside the OMP package when that is present.
|
|
14
12
|
async function fixExtensionTrees() {
|
|
15
13
|
// The opencode entrypoint is foreign to pi/omp loaders: never copy it there, and remove strays.
|
|
@@ -6,7 +6,7 @@ import { detectHosts, hostExtensionsDir, ompPackageDir } from './hosts.js';
|
|
|
6
6
|
import { askInteractiveChoice, askInteractiveConfirm, runInteractivePhase } from './interactive.js';
|
|
7
7
|
import { usageDbPath } from '../extensions/shared/usage-store.js';
|
|
8
8
|
import { pricesCachePath } from '../extensions/shared/pricing.js';
|
|
9
|
-
import { readTextIfExists, resolveRtkBinary } from '../extensions/lib/utils.js';
|
|
9
|
+
import { readTextIfExists, resolveRtkBinary, ruleBodyProblem } from '../extensions/lib/utils.js';
|
|
10
10
|
import { TREE_FILES } from './manifest.js';
|
|
11
11
|
async function runDoctor(recheck = false) {
|
|
12
12
|
console.log('\n=== Tersio Doctor ===');
|
|
@@ -30,7 +30,7 @@ async function runDoctor(recheck = false) {
|
|
|
30
30
|
cavemanIndexText: readTextIfExists(cavemanIndex),
|
|
31
31
|
cavemanRuleText: readTextIfExists(cavemanRule('rule.md')),
|
|
32
32
|
cavemanUltraText: readTextIfExists(cavemanRule('rule-ultra.md')),
|
|
33
|
-
|
|
33
|
+
cavemanMegacaveText: readTextIfExists(cavemanRule('rule-megacave.md')),
|
|
34
34
|
rtkIndexText: readTextIfExists(rtkIndex),
|
|
35
35
|
updaterIndexText: readTextIfExists(updaterIndex),
|
|
36
36
|
rtkMtime: rtkBin ? fs.stat(rtkBin).catch(() => null) : Promise.resolve(null),
|
|
@@ -42,7 +42,7 @@ async function runDoctor(recheck = false) {
|
|
|
42
42
|
const err = e;
|
|
43
43
|
return err.stdout?.trim() || err.stderr?.trim() || null;
|
|
44
44
|
}) : Promise.resolve(null);
|
|
45
|
-
const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, cavemanUltraText,
|
|
45
|
+
const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, cavemanUltraText, cavemanMegacaveText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
|
|
46
46
|
Promise.all([
|
|
47
47
|
probes.ompPkgText,
|
|
48
48
|
probes.configText,
|
|
@@ -56,7 +56,7 @@ async function runDoctor(recheck = false) {
|
|
|
56
56
|
Promise.all([
|
|
57
57
|
probes.cavemanRuleText,
|
|
58
58
|
probes.cavemanUltraText,
|
|
59
|
-
probes.
|
|
59
|
+
probes.cavemanMegacaveText,
|
|
60
60
|
probes.ruleMtime,
|
|
61
61
|
probes.rtkBinText,
|
|
62
62
|
probes.rtkMtime,
|
|
@@ -133,9 +133,16 @@ async function runDoctor(recheck = false) {
|
|
|
133
133
|
check('Prices feed', pricesMtime !== null, pricesAge || pricesCachePath());
|
|
134
134
|
section('Add-ons');
|
|
135
135
|
const ruleAge = ruleMtime ? `(updated ${relTime(Date.now() - ruleMtime.mtimeMs)} · ${absDate(ruleMtime.mtimeMs)})` : '';
|
|
136
|
-
const cavemanRules = [['rule.md', cavemanRuleText], ['rule-ultra.md', cavemanUltraText], ['rule-
|
|
136
|
+
const cavemanRules = [['rule.md', cavemanRuleText], ['rule-ultra.md', cavemanUltraText], ['rule-megacave.md', cavemanMegacaveText]];
|
|
137
137
|
const missingRules = cavemanRules.filter(([, text]) => text === null).map(([name]) => name);
|
|
138
138
|
check('Caveman rule', missingRules.length === 0, missingRules.length ? `missing: ${missingRules.join(', ')}` : ruleAge);
|
|
139
|
+
for (const [name, text] of cavemanRules) {
|
|
140
|
+
if (text === null)
|
|
141
|
+
continue;
|
|
142
|
+
const problem = ruleBodyProblem(text);
|
|
143
|
+
if (problem)
|
|
144
|
+
warnLine(`Caveman ${name}`, `unusable body (${problem}) — run: tersio install`);
|
|
145
|
+
}
|
|
139
146
|
const rtkAge = rtkMtime ? `(updated ${relTime(Date.now() - rtkMtime.mtimeMs)} · ${absDate(rtkMtime.mtimeMs)})` : '';
|
|
140
147
|
check('RTK binary', rtkBin !== null, rtkBinText === null ? 'not found in PATH' : [rtkVersion, rtkAge].filter(Boolean).join(' '));
|
|
141
148
|
// rtk_run needs a host exec API, which doctor cannot assume.
|
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
}
|