@krtclcdy/tersio 2.25.3 → 2.25.5
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 +14 -31
- package/dashboard/dist/index.html +18 -16
- package/dist/cli/backups.js +194 -0
- package/dist/cli/common.js +2 -2
- package/dist/cli/dashboard.js +125 -98
- package/dist/cli/doctor-fix.js +42 -45
- package/dist/cli/doctor.js +70 -8
- package/dist/cli/hosts.js +4 -4
- package/dist/cli/install.js +106 -38
- package/dist/cli/manifest.js +45 -16
- package/dist/cli/profile.js +1 -1
- package/dist/cli/uninstall.js +21 -5
- package/dist/cli/update.js +3 -3
- package/dist/cli/usage.js +75 -4
- package/dist/extensions/{caveman-session → caveman}/index.js +10 -3
- package/dist/extensions/{combo-toggle → combo}/index.js +3 -3
- package/dist/extensions/{tersio-commands → commands}/index.js +1 -1
- package/dist/extensions/rtk/filter.js +87 -0
- package/dist/extensions/{rtk-session/index.js → rtk/session.js} +4 -3
- package/dist/extensions/{opencode/server.js → server.js} +52 -8
- package/dist/extensions/shared/agent-stats.js +1262 -0
- package/dist/extensions/shared/brand-marks.js +154 -0
- package/dist/extensions/shared/host.js +18 -12
- package/dist/extensions/shared/plugin-settings.js +1 -1
- package/dist/extensions/shared/pricing.js +91 -27
- package/dist/extensions/shared/session-state.js +3 -1
- package/dist/extensions/shared/usage-ledger.js +79 -17
- package/dist/extensions/shared/usage-store.js +106 -22
- package/dist/extensions/{lib → shared}/utils.js +1 -1
- package/dist/extensions/{ai-addons-updater → updater}/index.js +15 -5
- package/dist/probe-catalog.js +48 -0
- package/dist/probe-catalog188.js +44 -0
- package/dist/tersio.js +2 -2
- package/extensions/{caveman-session → caveman}/index.ts +10 -3
- package/extensions/{combo-toggle → combo}/index.ts +3 -3
- package/extensions/{tersio-commands → commands}/index.ts +1 -1
- package/extensions/rtk/filter.ts +90 -0
- package/extensions/{rtk-session/index.ts → rtk/session.ts} +4 -3
- package/extensions/{opencode/server.ts → server.ts} +46 -9
- package/extensions/shared/agent-stats.ts +1657 -0
- package/extensions/shared/brand-marks.ts +163 -0
- package/extensions/shared/host.ts +26 -13
- package/extensions/shared/plugin-settings.ts +1 -1
- package/extensions/shared/pricing.ts +107 -28
- package/extensions/shared/session-state.ts +3 -1
- package/extensions/shared/types.ts +10 -2
- package/extensions/shared/usage-ledger.ts +94 -19
- package/extensions/shared/usage-store.ts +106 -22
- package/extensions/{lib → shared}/utils.ts +1 -1
- package/extensions/{ai-addons-updater → updater}/index.ts +17 -7
- package/package.json +13 -12
- 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
- /package/dist/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
- /package/dist/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
- /package/dist/extensions/{caveman-session → caveman}/rule.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule.md +0 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Data snapshots: what tersio keeps, where the copies live, and how one is put back.
|
|
2
|
+
//
|
|
3
|
+
// A snapshot is a directory with a manifest, not a single file, so one copy holds the whole set.
|
|
4
|
+
// Older installs wrote a lone `usage-<stamp>.db`; those still list and restore, over the ledger alone.
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { agentDbPath, statsDbPath } from '../extensions/shared/agent-stats.js';
|
|
10
|
+
import { rtkDbPath } from '../extensions/shared/rtk-gain.js';
|
|
11
|
+
import { pricesCachePath } from '../extensions/shared/pricing.js';
|
|
12
|
+
import { usageDbPath } from '../extensions/shared/usage-store.js';
|
|
13
|
+
import { resetMarkerPath } from '../extensions/shared/usage-ledger.js';
|
|
14
|
+
import { tersioHome } from '../extensions/shared/utils.js';
|
|
15
|
+
/** Snapshots kept on disk; the oldest is pruned first. Six keeps the list past one screen, so
|
|
16
|
+
* the pane scrolls instead of showing the whole history at once. */
|
|
17
|
+
export const BACKUP_KEEP = 6;
|
|
18
|
+
/** Where a snapshot's files live. */
|
|
19
|
+
export function backupDir() {
|
|
20
|
+
return path.join(tersioHome(), 'backups');
|
|
21
|
+
}
|
|
22
|
+
/** A snapshot directory's manifest, read back out with its files. */
|
|
23
|
+
function manifestPath(dir) {
|
|
24
|
+
return path.join(dir, 'manifest.json');
|
|
25
|
+
}
|
|
26
|
+
/** Every file this build knows how to snapshot, whether or not it exists yet. */
|
|
27
|
+
export function dataSources() {
|
|
28
|
+
const sources = [
|
|
29
|
+
['Usage ledger', usageDbPath()],
|
|
30
|
+
['Reset watermark', resetMarkerPath()],
|
|
31
|
+
['Settings', path.join(tersioHome(), 'settings.json')],
|
|
32
|
+
['Price cache', pricesCachePath()],
|
|
33
|
+
['Model price cache', path.join(tersioHome(), 'prices-modelsdev.json')],
|
|
34
|
+
['Agent stats', statsDbPath()],
|
|
35
|
+
['Agent quota windows', agentDbPath()],
|
|
36
|
+
['Command history', rtkDbPath()],
|
|
37
|
+
];
|
|
38
|
+
return sources.map(([label, source]) => ({ label, source, bytes: sizeOf(source) }));
|
|
39
|
+
}
|
|
40
|
+
/** Byte size of a file, zero when it is absent or unreadable. */
|
|
41
|
+
function sizeOf(file) {
|
|
42
|
+
try {
|
|
43
|
+
return statSync(file).size;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Snapshot label for a legacy single-file backup, so the list reads the same for both. */
|
|
50
|
+
function legacyLabel(name) {
|
|
51
|
+
return name.startsWith('usage-') ? 'Usage ledger' : 'Snapshot';
|
|
52
|
+
}
|
|
53
|
+
/** Newest first: the directories this build wrote, then the lone `usage-*.db` files. */
|
|
54
|
+
export function listDataBackups() {
|
|
55
|
+
let names = [];
|
|
56
|
+
try {
|
|
57
|
+
names = readdirSync(backupDir());
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const dirs = [];
|
|
63
|
+
const legacy = [];
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
const full = path.join(backupDir(), name);
|
|
66
|
+
let stat;
|
|
67
|
+
try {
|
|
68
|
+
stat = statSync(full);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (stat.isDirectory()) {
|
|
74
|
+
const files = readManifest(full);
|
|
75
|
+
if (files.length === 0)
|
|
76
|
+
continue;
|
|
77
|
+
dirs.push({ name, path: full, mtime: stat.mtimeMs, bytes: totalOf(full), files, legacy: false });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (/^usage-\d{14}\.db$/.test(name)) {
|
|
81
|
+
legacy.push({
|
|
82
|
+
name,
|
|
83
|
+
path: full,
|
|
84
|
+
mtime: stat.mtimeMs,
|
|
85
|
+
bytes: stat.size,
|
|
86
|
+
files: [{ label: legacyLabel(name), source: usageDbPath(), bytes: stat.size }],
|
|
87
|
+
legacy: true,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [...dirs, ...legacy].toSorted((a, b) => b.mtime - a.mtime);
|
|
92
|
+
}
|
|
93
|
+
/** The files a snapshot directory holds, read from its manifest. */
|
|
94
|
+
function readManifest(dir) {
|
|
95
|
+
try {
|
|
96
|
+
const raw = JSON.parse(readFileSync(manifestPath(dir), 'utf8'));
|
|
97
|
+
if (!Array.isArray(raw))
|
|
98
|
+
return [];
|
|
99
|
+
return raw
|
|
100
|
+
.map((entry) => {
|
|
101
|
+
const record = (entry ?? {});
|
|
102
|
+
const source = String(record.source ?? '');
|
|
103
|
+
const name = path.basename(source);
|
|
104
|
+
const file = path.join(dir, name);
|
|
105
|
+
const bytes = sizeOf(file);
|
|
106
|
+
if (!source || bytes === 0)
|
|
107
|
+
return null;
|
|
108
|
+
return { label: String(record.label ?? name), source, bytes };
|
|
109
|
+
})
|
|
110
|
+
.filter((entry) => entry !== null);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Disk size of a snapshot, counting the manifest. */
|
|
117
|
+
function totalOf(dir) {
|
|
118
|
+
return sizeOf(manifestPath(dir)) + readdirSync(dir).reduce((sum, name) => sum + sizeOf(path.join(dir, name)), 0);
|
|
119
|
+
}
|
|
120
|
+
/** Put the sqlite ledger's write-ahead log back in the main file, so a copy is one whole database. */
|
|
121
|
+
function checkpointLedger() {
|
|
122
|
+
try {
|
|
123
|
+
execFileSync('sqlite3', [usageDbPath(), 'PRAGMA wal_checkpoint(TRUNCATE);'], { stdio: 'ignore', timeout: 10_000 });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* no sqlite3, or no ledger: the plain file copy is the best available */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Take a snapshot now: one copy of every file that exists, under a stamp directory, then keep only the newest {@link BACKUP_KEEP}. */
|
|
130
|
+
export async function createDataBackup() {
|
|
131
|
+
// The stamp keeps milliseconds: two snapshots in the same second must not land in one directory.
|
|
132
|
+
const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '');
|
|
133
|
+
const dir = path.join(backupDir(), `data-${stamp}`);
|
|
134
|
+
checkpointLedger();
|
|
135
|
+
await fs.mkdir(dir, { recursive: true });
|
|
136
|
+
const files = [];
|
|
137
|
+
for (const source of dataSources()) {
|
|
138
|
+
if (source.bytes === 0)
|
|
139
|
+
continue;
|
|
140
|
+
const target = path.join(dir, path.basename(source.source));
|
|
141
|
+
await fs.copyFile(source.source, target);
|
|
142
|
+
files.push({ ...source, bytes: sizeOf(target) });
|
|
143
|
+
}
|
|
144
|
+
await fs.writeFile(manifestPath(dir), JSON.stringify(files.map(({ label, source }) => ({ label, source }))), 'utf8');
|
|
145
|
+
await pruneBackups();
|
|
146
|
+
return { name: path.basename(dir), path: dir, mtime: Date.now(), bytes: files.reduce((sum, f) => sum + f.bytes, 0), files, legacy: false };
|
|
147
|
+
}
|
|
148
|
+
/** Keep the newest {@link BACKUP_KEEP} snapshots and drop the rest, legacy files included. */
|
|
149
|
+
export async function pruneBackups() {
|
|
150
|
+
const all = listDataBackups();
|
|
151
|
+
for (const old of all.slice(BACKUP_KEEP)) {
|
|
152
|
+
await fs.rm(old.path, { recursive: true, force: true }).catch(() => { });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** A snapshot name resolves to a path inside the backup directory, or null when it does not. */
|
|
156
|
+
function resolveBackup(name) {
|
|
157
|
+
if (!name || path.basename(name) !== name || /[\\/]/.test(name))
|
|
158
|
+
return null;
|
|
159
|
+
const found = listDataBackups().find((entry) => entry.name === name);
|
|
160
|
+
return found ?? null;
|
|
161
|
+
}
|
|
162
|
+
/** Put a snapshot back: every file it holds is copied over its live source. A file it lacks is left alone, so a restore never deletes what it does not carry. */
|
|
163
|
+
export async function restoreDataBackup(name) {
|
|
164
|
+
const found = resolveBackup(name);
|
|
165
|
+
if (!found)
|
|
166
|
+
return false;
|
|
167
|
+
if (found.legacy) {
|
|
168
|
+
await fs.mkdir(path.dirname(usageDbPath()), { recursive: true });
|
|
169
|
+
await fs.copyFile(found.path, usageDbPath());
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
for (const file of found.files) {
|
|
173
|
+
const from = path.join(found.path, path.basename(file.source));
|
|
174
|
+
await fs.mkdir(path.dirname(file.source), { recursive: true });
|
|
175
|
+
await fs.copyFile(from, file.source);
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
/** Remove one snapshot. Only ever touches a path inside the backup directory. */
|
|
180
|
+
export async function deleteDataBackup(name) {
|
|
181
|
+
const found = resolveBackup(name);
|
|
182
|
+
if (!found)
|
|
183
|
+
return false;
|
|
184
|
+
await fs.rm(found.path, { recursive: true, force: true });
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
/** Total size of every snapshot on disk, for the pane's footer. */
|
|
188
|
+
export function backupDiskBytes() {
|
|
189
|
+
return listDataBackups().reduce((sum, entry) => sum + entry.bytes, 0);
|
|
190
|
+
}
|
|
191
|
+
/** The home directory the snapshots live under, for the pane's path line. */
|
|
192
|
+
export function backupHome() {
|
|
193
|
+
return tersioHome();
|
|
194
|
+
}
|
package/dist/cli/common.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { findPackageRoot, readTextIfExists } from '../extensions/
|
|
8
|
+
import { findPackageRoot, readTextIfExists } from '../extensions/shared/utils.js';
|
|
9
9
|
import { parseCurrencyFlag, readStoredCurrency } from './currency.js';
|
|
10
10
|
const IS_WINDOWS = process.platform === 'win32';
|
|
11
11
|
const RTK_BINARY_NAME = IS_WINDOWS ? 'rtk.exe' : 'rtk';
|
|
@@ -300,4 +300,4 @@ function relTime(ageMs) {
|
|
|
300
300
|
const weeks = Math.floor(days / 7);
|
|
301
301
|
return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
|
|
302
302
|
}
|
|
303
|
-
export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, DIAG_SCHEDULES, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, settingArg, unknownCommand, install, update, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, allowUnverified, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, subagentMarkerFlags, subagentMarkersGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey,
|
|
303
|
+
export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, DIAG_SCHEDULES, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, settingArg, unknownCommand, install, update, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, allowUnverified, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, subagentMarkerFlags, subagentMarkersGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, writeConfigLines, ensureExtensionInConfig, removeExtensionFromConfig, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -6,17 +6,17 @@ import { promises as fs } from 'node:fs';
|
|
|
6
6
|
import http from 'node:http';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usage-ledger.js';
|
|
10
9
|
import { pricesCachePath } from '../extensions/shared/pricing.js';
|
|
11
|
-
import { summarizeUsage } from './usage.js';
|
|
12
|
-
import { backupUsageDb, deleteUsageBackup, listUsageBackups, restoreUsageBackup, usageDbPath } from '../extensions/shared/usage-store.js';
|
|
13
10
|
import { isCurrencyCode } from './currency.js';
|
|
14
11
|
import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, } from './common.js';
|
|
15
12
|
import { BACKUP_SCHEDULES, formatCliStatus, storedProfile, writePluginSettings } from './profile.js';
|
|
16
13
|
import { normalizeComboLevel } from '../extensions/shared/session-state.js';
|
|
17
14
|
import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS } from './common.js';
|
|
18
15
|
import { PACKAGE_NAME } from './common.js';
|
|
19
|
-
import { findPackageRoot, resolveRtkBinary } from '../extensions/
|
|
16
|
+
import { findPackageRoot, resolveRtkBinary } from '../extensions/shared/utils.js';
|
|
17
|
+
import { BACKUP_KEEP, backupDir, backupDiskBytes, createDataBackup, dataSources, deleteDataBackup, listDataBackups, restoreDataBackup } from './backups.js';
|
|
18
|
+
import { readBackupSchedule } from '../extensions/shared/usage-store.js';
|
|
19
|
+
import { agentDbPath, emptyRequestEntry, isAgentView, isRangeKey, readAgentRequestEntry, readAgentStats, readSessionTrace, statsDbPath } from '../extensions/shared/agent-stats.js';
|
|
20
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');
|
|
@@ -49,8 +49,45 @@ async function brandDataUri() {
|
|
|
49
49
|
const brand = await fs.readFile(DASHBOARD_BRAND);
|
|
50
50
|
return `data:image/webp;base64,${brand.toString('base64')}`;
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
/** The range a page opens on, matching the reference dashboard. */
|
|
53
|
+
const DEFAULT_AGENT_RANGE = '24h';
|
|
54
|
+
/** The omp aggregate spawns a sqlite3 process per query group, so a repeat poll reuses it. */
|
|
55
|
+
const AGENT_REFRESH_MS = 5000;
|
|
56
|
+
let agentMemo = null;
|
|
57
|
+
function fileStamp(file) {
|
|
58
|
+
try {
|
|
59
|
+
const stat = statSync(file);
|
|
60
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return '-';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Reads the omp databases, unless the same window and view were read recently and nothing moved. */
|
|
67
|
+
function agentStats(range, view = 'all') {
|
|
68
|
+
const stamp = `${fileStamp(statsDbPath())}|${fileStamp(agentDbPath())}`;
|
|
69
|
+
const held = agentMemo;
|
|
70
|
+
if (held && held.range === range && held.view === view && held.stamp === stamp && Date.now() - held.at < AGENT_REFRESH_MS) {
|
|
71
|
+
return held.value;
|
|
72
|
+
}
|
|
73
|
+
const value = readAgentStats(range, view);
|
|
74
|
+
agentMemo = { range, view, stamp, at: Date.now(), value };
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
/** `?range=` on any of the omp routes; an unknown value falls back to the default. */
|
|
78
|
+
function queryParam(url, name) {
|
|
79
|
+
return (url.split('?')[1] ?? '').split('&').find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1) ?? null;
|
|
80
|
+
}
|
|
81
|
+
function rangeFrom(url) {
|
|
82
|
+
const raw = queryParam(url, 'range');
|
|
83
|
+
return isRangeKey(raw) ? raw : DEFAULT_AGENT_RANGE;
|
|
84
|
+
}
|
|
85
|
+
function viewFrom(url) {
|
|
86
|
+
const raw = queryParam(url, 'view');
|
|
87
|
+
return isAgentView(raw) ? raw : 'all';
|
|
88
|
+
}
|
|
89
|
+
function agentJson(range, view) {
|
|
90
|
+
return JSON.stringify({ range, view, agent: agentStats(range, view) });
|
|
54
91
|
}
|
|
55
92
|
// Runs outside any agent session, so it reports the persisted defaults.
|
|
56
93
|
async function statusJson() {
|
|
@@ -374,50 +411,6 @@ async function saveDashboardCurrency(raw) {
|
|
|
374
411
|
await writePluginSettings(profile, {});
|
|
375
412
|
return code;
|
|
376
413
|
}
|
|
377
|
-
export const EXPORT_FORMATS = ['json', 'jsonl', 'csv'];
|
|
378
|
-
export function exportRows(report) {
|
|
379
|
-
return report.recent.map((r) => ({
|
|
380
|
-
timestamp: new Date(r.t).toISOString(),
|
|
381
|
-
local: new Date(r.t).toLocaleString(),
|
|
382
|
-
agent: r.h ?? 'pi',
|
|
383
|
-
model: r.m,
|
|
384
|
-
input: r.i,
|
|
385
|
-
output: r.o,
|
|
386
|
-
cacheRead: r.cr ?? 0,
|
|
387
|
-
cacheWrite: r.cw ?? 0,
|
|
388
|
-
costUsd: r.usd,
|
|
389
|
-
elapsedMs: r.d,
|
|
390
|
-
status: r.st,
|
|
391
|
-
}));
|
|
392
|
-
}
|
|
393
|
-
export function csvCell(v) {
|
|
394
|
-
const s = v === undefined || v === null ? '' : String(v);
|
|
395
|
-
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
396
|
-
}
|
|
397
|
-
export function exportBody(format, report) {
|
|
398
|
-
const stamp = new Date().toISOString().slice(0, 10);
|
|
399
|
-
if (format === 'json') {
|
|
400
|
-
return { body: JSON.stringify({ exportedAt: new Date().toISOString(), version: report.version, source: report.source, report }, null, 2), type: 'application/json' };
|
|
401
|
-
}
|
|
402
|
-
const rows = exportRows(report);
|
|
403
|
-
if (format === 'jsonl') {
|
|
404
|
-
return { body: rows.map((r) => JSON.stringify(r)).join('\n') + '\n', type: 'application/x-ndjson' };
|
|
405
|
-
}
|
|
406
|
-
const cols = Object.keys(rows[0] ?? { timestamp: '', agent: '' });
|
|
407
|
-
const head = cols.join(',');
|
|
408
|
-
const body = [head, ...rows.map((r) => cols.map((c) => csvCell(r[c])).join(','))].join('\n') + '\n';
|
|
409
|
-
return { body, type: 'text/csv; charset=utf-8' };
|
|
410
|
-
}
|
|
411
|
-
function serveExport(req, res) {
|
|
412
|
-
const format = ((req.url || '').split('?')[1] || '').match(/format=([a-z]+)/)?.[1];
|
|
413
|
-
const fmt = format && EXPORT_FORMATS.includes(format) ? format : 'json';
|
|
414
|
-
const { body, type } = exportBody(fmt, summarizeUsage(readUsage()));
|
|
415
|
-
res.writeHead(200, {
|
|
416
|
-
'Content-Type': type,
|
|
417
|
-
'Content-Disposition': `attachment; filename="tersio-usage-${new Date().toISOString().slice(0, 10)}.${fmt === 'jsonl' ? 'ndjson' : fmt}"`,
|
|
418
|
-
});
|
|
419
|
-
res.end(body);
|
|
420
|
-
}
|
|
421
414
|
function readBody(req) {
|
|
422
415
|
return new Promise((resolve) => {
|
|
423
416
|
let body = '';
|
|
@@ -448,73 +441,85 @@ function parseOwnJson(json, field) {
|
|
|
448
441
|
}
|
|
449
442
|
return parsed;
|
|
450
443
|
}
|
|
451
|
-
async function exportDashboard(exportFile) {
|
|
444
|
+
async function exportDashboard(exportFile, currency) {
|
|
452
445
|
requireDashboardBundle();
|
|
453
446
|
const [bundle, icon] = await Promise.all([readSegment(DASHBOARD_INDEX), brandDataUri()]);
|
|
447
|
+
// The bundle is minified, so the brand fetch reaches the file as `fetch(\`brand.webp\`)` or
|
|
448
|
+
// `fetch("brand.webp")` depending on the bundler's quote choice. Match either, or the exported
|
|
449
|
+
// card silently loses its mark. `atob` returns one char per byte, and a Blob encodes a string
|
|
450
|
+
// as UTF-8, so the bytes go through a Uint8Array first: a webp is binary and every byte at or
|
|
451
|
+
// above 0x80 would otherwise double and corrupt the image.
|
|
452
|
+
const b64 = icon.split(',')[1] ?? '';
|
|
453
|
+
// atob yields one char per byte; a Blob encodes a string as UTF-8, so the bytes pass through a
|
|
454
|
+
// Uint8Array or every byte at or above 0x80 doubles and the webp comes out corrupt.
|
|
455
|
+
const brandBytes = `(() => { const c = atob(${JSON.stringify(b64)}); const v = new Uint8Array(c.length); for (let i = 0; i < c.length; i++) v[i] = c.charCodeAt(i); return v; })()`;
|
|
454
456
|
const inline = bundle
|
|
455
|
-
.replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({
|
|
456
|
-
.
|
|
457
|
-
.
|
|
458
|
-
|
|
457
|
+
.replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ currency, agent: agentStats(DEFAULT_AGENT_RANGE), health: parseOwnJson(healthJson(), 'health'), doctor: getDoctorReport(false) }))};`)
|
|
458
|
+
// A mark reaches the bundle three ways: an HTML attribute in `index.html`, and a JSX prop
|
|
459
|
+
// esbuild compiled with a backtick. Match any of `"`, `'`, and `` ` ``, or an exported file
|
|
460
|
+
// keeps a bare reference that file:// cannot resolve.
|
|
461
|
+
.replace(/((?:src|href)[=:][ \t]*)([`"'])brand\.webp\2/g, (_all, head, quote) => `${head}${quote}${icon}${quote}`)
|
|
462
|
+
.replace(/fetch\(([`"])brand\.webp\1\)/g, () => `Promise.resolve({ ok: true, blob: async () => new Blob([${brandBytes}], { type: "image/webp" }) })`);
|
|
459
463
|
await fs.writeFile(exportFile, inline, 'utf8');
|
|
460
464
|
console.log(`[ok] Dashboard exported → ${exportFile}`);
|
|
461
465
|
}
|
|
462
466
|
async function runDashboard(options) {
|
|
463
467
|
if (options.exportFile) {
|
|
464
|
-
await exportDashboard(options.exportFile);
|
|
468
|
+
await exportDashboard(options.exportFile, options.currency);
|
|
465
469
|
return;
|
|
466
470
|
}
|
|
467
471
|
requireDashboardBundle();
|
|
468
472
|
const server = http.createServer(async (req, res) => {
|
|
469
|
-
if (req.url === '/
|
|
470
|
-
const rows = clearUsageLedger();
|
|
471
|
-
markReset();
|
|
472
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
473
|
-
res.end(JSON.stringify({ ok: true, rows }));
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
if (req.url === '/backups' && req.method === 'GET') {
|
|
477
|
-
const rows = listUsageBackups().map((b) => ({ file: path.basename(b.file), mtime: b.mtime, size: b.size }));
|
|
473
|
+
if (req.url === '/api/data' && req.method === 'GET') {
|
|
478
474
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
479
|
-
res.end(JSON.stringify({
|
|
475
|
+
res.end(JSON.stringify({
|
|
476
|
+
dir: backupDir(),
|
|
477
|
+
keep: BACKUP_KEEP,
|
|
478
|
+
sources: dataSources(),
|
|
479
|
+
backups: listDataBackups(),
|
|
480
|
+
schedule: readBackupSchedule(),
|
|
481
|
+
bytes: backupDiskBytes(),
|
|
482
|
+
}));
|
|
480
483
|
return;
|
|
481
484
|
}
|
|
482
|
-
if (req.url === '/
|
|
483
|
-
|
|
484
|
-
backupUsageDb(usageDbPath());
|
|
485
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
486
|
-
res.end(JSON.stringify({ ok: true }));
|
|
487
|
-
}
|
|
488
|
-
catch {
|
|
489
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
490
|
-
res.end(JSON.stringify({ ok: false, error: 'backup failed' }));
|
|
491
|
-
}
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
if (req.url === '/backups/delete' && req.method === 'POST') {
|
|
495
|
-
let file = '';
|
|
485
|
+
if (req.url === '/api/data' && req.method === 'POST') {
|
|
486
|
+
let body = {};
|
|
496
487
|
try {
|
|
497
|
-
|
|
488
|
+
body = JSON.parse(await readBody(req)) ?? {};
|
|
498
489
|
}
|
|
499
490
|
catch {
|
|
500
|
-
|
|
491
|
+
body = {};
|
|
501
492
|
}
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
if (req.url === '/backups/restore' && req.method === 'POST') {
|
|
508
|
-
let file = '';
|
|
493
|
+
const action = String(body.action ?? '');
|
|
494
|
+
const name = String(body.name ?? '');
|
|
495
|
+
// A missing sqlite3, or a locked file, is a 500 with a message rather than a silent no-op.
|
|
509
496
|
try {
|
|
510
|
-
|
|
497
|
+
if (action === 'create') {
|
|
498
|
+
const made = await createDataBackup();
|
|
499
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
500
|
+
res.end(JSON.stringify({ ok: true, name: made.name, files: made.files.length }));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (action === 'restore') {
|
|
504
|
+
const ok = await restoreDataBackup(name);
|
|
505
|
+
res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
506
|
+
res.end(JSON.stringify(ok ? { ok: true, name } : { ok: false, error: 'unknown snapshot' }));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (action === 'delete') {
|
|
510
|
+
const ok = await deleteDataBackup(name);
|
|
511
|
+
res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
512
|
+
res.end(JSON.stringify(ok ? { ok: true, name } : { ok: false, error: 'unknown snapshot' }));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
511
515
|
}
|
|
512
|
-
catch {
|
|
513
|
-
|
|
516
|
+
catch (error) {
|
|
517
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
518
|
+
res.end(JSON.stringify({ ok: false, error: error.message || 'backup failed' }));
|
|
519
|
+
return;
|
|
514
520
|
}
|
|
515
|
-
|
|
516
|
-
res.
|
|
517
|
-
res.end(JSON.stringify(ok ? { ok: true, file } : { ok: false, error: 'unknown backup' }));
|
|
521
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
522
|
+
res.end(JSON.stringify({ ok: false, error: 'unknown action' }));
|
|
518
523
|
return;
|
|
519
524
|
}
|
|
520
525
|
if (req.url === '/settings' && req.method === 'GET') {
|
|
@@ -526,6 +531,7 @@ async function runDashboard(options) {
|
|
|
526
531
|
rtkDefault: profile.rtkDefault,
|
|
527
532
|
ponytailDefault: profile.ponytailDefault,
|
|
528
533
|
backupSchedule: profile.backupSchedule,
|
|
534
|
+
currency: options.currency,
|
|
529
535
|
}));
|
|
530
536
|
});
|
|
531
537
|
return;
|
|
@@ -568,13 +574,34 @@ async function runDashboard(options) {
|
|
|
568
574
|
}
|
|
569
575
|
return;
|
|
570
576
|
}
|
|
571
|
-
if (req.url === '/
|
|
577
|
+
if (req.url === '/api/agent' || req.url?.startsWith('/api/agent?')) {
|
|
578
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
579
|
+
res.end(agentJson(rangeFrom(req.url), viewFrom(req.url)));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (req.url?.startsWith('/api/agent/session')) {
|
|
583
|
+
const file = decodeURIComponent(queryParam(req.url, 'file') ?? '');
|
|
584
|
+
const body = file ? readSessionTrace(file) : {
|
|
585
|
+
sessionFile: '', project: '', title: '', cwd: '', startedAt: 0, endedAt: 0,
|
|
586
|
+
tracks: [], markers: [], summary: {
|
|
587
|
+
wallMs: 0, modelMs: 0, toolMs: 0, idleMs: 0, turns: 0, requests: 0, toolCalls: 0, agents: 0,
|
|
588
|
+
totalTokens: 0, costUsd: 0,
|
|
589
|
+
},
|
|
590
|
+
toolStats: [], truncated: false,
|
|
591
|
+
};
|
|
592
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
593
|
+
res.end(JSON.stringify(body));
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (req.url?.startsWith('/api/agent/entry')) {
|
|
597
|
+
const file = decodeURIComponent(queryParam(req.url, 'file') ?? '');
|
|
598
|
+
const entry = decodeURIComponent(queryParam(req.url, 'entry') ?? '');
|
|
599
|
+
const body = file && entry ? readAgentRequestEntry(file, entry) : emptyRequestEntry();
|
|
572
600
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
573
|
-
res.end(
|
|
601
|
+
res.end(JSON.stringify(body));
|
|
574
602
|
return;
|
|
575
603
|
}
|
|
576
604
|
if (req.url?.startsWith('/export')) {
|
|
577
|
-
serveExport(req, res);
|
|
578
605
|
return;
|
|
579
606
|
}
|
|
580
607
|
if (req.url === '/health') {
|
package/dist/cli/doctor-fix.js
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
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 { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, removeExtensionFromConfig, dryRun, verbose, debug, execP, readPluginsPackage,
|
|
4
|
+
import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, removeExtensionFromConfig, dryRun, verbose, debug, execP, readPluginsPackage, } from './common.js';
|
|
5
5
|
import { execNetwork, sayTagged } from './interactive.js';
|
|
6
6
|
import { wireRtkOmp, wireRtkOpencode, wireRtkPi, ensureRtkInConfig } from './rtk-wiring.js';
|
|
7
|
-
import { RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/
|
|
7
|
+
import { RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/shared/utils.js';
|
|
8
8
|
import { runLatestUpdate } from './update.js';
|
|
9
|
-
import {
|
|
9
|
+
import { OPENCODE_ENTRY, TREE_FILES, syncTreeFiles, treeFilesFor } from './manifest.js';
|
|
10
10
|
import { detectHosts, hostExtensionsDir } from './hosts.js';
|
|
11
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.
|
|
12
12
|
async function fixExtensionTrees() {
|
|
13
|
-
// The
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
// The OpenCode entry is foreign to the pi and omp loaders: it imports @opencode/plugin, which
|
|
14
|
+
// those hosts do not carry. One shared filter, so a repair cannot drop it into the wrong tree.
|
|
15
|
+
const forHost = (id) => treeFilesFor(id);
|
|
16
|
+
// A tree from before the entry moved to the extensions root held a nested copy, and a repair
|
|
17
|
+
// written against the old filter may have left the entry at the root. Remove both strays.
|
|
18
|
+
const scrubOpencodeEntry = async (dest) => {
|
|
19
|
+
for (const target of [path.join(dest, OPENCODE_ENTRY), path.join(dest, 'opencode')]) {
|
|
20
|
+
if (!existsSync(target))
|
|
21
|
+
continue;
|
|
22
|
+
if (dryRun) {
|
|
23
|
+
sayTagged(` [dry-run] would remove foreign ${target}`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
27
|
+
sayTagged(` [rm] foreign ${target}`);
|
|
22
28
|
}
|
|
23
|
-
await fs.rm(dir, { recursive: true, force: true });
|
|
24
|
-
sayTagged(` [rm] foreign ${dir}`);
|
|
25
29
|
};
|
|
26
30
|
for (const host of detectHosts()) {
|
|
27
31
|
const dest = hostExtensionsDir(host.id);
|
|
@@ -29,20 +33,15 @@ async function fixExtensionTrees() {
|
|
|
29
33
|
const present = forHost(host.id).filter((file) => existsSync(path.join(dest, file))).length;
|
|
30
34
|
if (present === 0) {
|
|
31
35
|
if (host.id !== 'opencode')
|
|
32
|
-
await
|
|
36
|
+
await scrubOpencodeEntry(dest);
|
|
33
37
|
continue;
|
|
34
38
|
}
|
|
35
39
|
await fs.mkdir(dest, { recursive: true });
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (text === null)
|
|
40
|
-
sayTagged(` [warn] bundled source missing: ${from}`);
|
|
41
|
-
else
|
|
42
|
-
await writeIfChanged(path.join(dest, file), text, { dryRun, verbose });
|
|
43
|
-
}
|
|
40
|
+
const missing = await syncTreeFiles(dest, forHost(host.id), { dryRun, verbose });
|
|
41
|
+
for (const from of missing)
|
|
42
|
+
sayTagged(` [warn] bundled source missing: ${from}`);
|
|
44
43
|
if (host.id !== 'opencode')
|
|
45
|
-
await
|
|
44
|
+
await scrubOpencodeEntry(dest);
|
|
46
45
|
if (!dryRun)
|
|
47
46
|
sayTagged(` [ok] ${host.label} extension tree: ${dest}`);
|
|
48
47
|
}
|
|
@@ -53,11 +52,14 @@ async function fixOpencodeEntry() {
|
|
|
53
52
|
const { HOME } = await import('./common.js');
|
|
54
53
|
const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
|
|
55
54
|
const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
await
|
|
55
|
+
// The loader resolves <pluginDir>/server.ts, and the entry is that file. A tree from before the
|
|
56
|
+
// entry moved to the extensions root held a root shim that re-exported a nested copy, so the
|
|
57
|
+
// real entry is written in place and the stale nested tree is dropped.
|
|
58
|
+
const nested = path.join(dir, 'opencode');
|
|
59
|
+
const staleNested = (await readTextIfExists(path.join(nested, 'server.ts'))) !== null;
|
|
60
|
+
await syncTreeFiles(dir, TREE_FILES.filter((file) => !file.includes('/')), { dryRun, verbose });
|
|
61
|
+
if (staleNested && !dryRun)
|
|
62
|
+
await fs.rm(nested, { recursive: true, force: true }).catch(() => { });
|
|
61
63
|
if (dryRun)
|
|
62
64
|
sayTagged(` [dry-run] would remove the redundant rtk plugin entry`);
|
|
63
65
|
else
|
|
@@ -91,23 +93,16 @@ async function fixExtensions(pluginsDir) {
|
|
|
91
93
|
const pluginExtDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio', 'extensions');
|
|
92
94
|
await fs.mkdir(pluginExtDir, { recursive: true });
|
|
93
95
|
// Rule bodies are never overwritten: they can be newer upstream fetches.
|
|
94
|
-
const ruleFile = (f) => /^caveman
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
continue;
|
|
99
|
-
const text = await readTextIfExists(sourcePath(file));
|
|
100
|
-
if (text === null)
|
|
101
|
-
sayTagged(` [warn] bundled source missing: ${sourcePath(file)}`);
|
|
102
|
-
else
|
|
103
|
-
await writeIfChanged(dest, text, { dryRun, verbose });
|
|
104
|
-
}
|
|
96
|
+
const ruleFile = (f) => /^caveman\/rule.*\.md$/.test(f);
|
|
97
|
+
const missing = await syncTreeFiles(pluginExtDir, TREE_FILES, { dryRun, verbose }, (file) => ruleFile(file) && existsSync(path.join(pluginExtDir, ...file.split('/'))));
|
|
98
|
+
for (const from of missing)
|
|
99
|
+
sayTagged(` [warn] bundled source missing: ${from}`);
|
|
105
100
|
// Writing is not atomic, so re-read rather than assume the tree is whole.
|
|
106
101
|
if (dryRun)
|
|
107
102
|
return;
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
sayTagged(` [fail] tree incomplete: ${
|
|
103
|
+
const stillMissing = TREE_FILES.filter((file) => !existsSync(path.join(pluginExtDir, ...file.split('/'))));
|
|
104
|
+
if (stillMissing.length > 0)
|
|
105
|
+
sayTagged(` [fail] tree incomplete: ${stillMissing.join(', ')}`);
|
|
111
106
|
else
|
|
112
107
|
sayTagged(` [ok] extension tree: ${TREE_FILES.length} files`);
|
|
113
108
|
}
|
|
@@ -127,7 +122,9 @@ async function fixRegistrations(agentDir, pluginsDir) {
|
|
|
127
122
|
if ((await readTextIfExists(rtkTs)) !== null)
|
|
128
123
|
await ensureRtkInConfig({ dryRun, verbose });
|
|
129
124
|
const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
|
|
130
|
-
|
|
125
|
+
// A tree from before the fold still names `combo-toggle`; both lines name the same module.
|
|
126
|
+
await removeExtensionFromConfig(configPath, path.join(extDir, 'combo', 'index.ts'), 'manifest-loaded combo', { dryRun, verbose });
|
|
127
|
+
await removeExtensionFromConfig(configPath, path.join(extDir, 'combo-toggle', 'index.ts'), 'retired manifest-loaded combo', { dryRun, verbose });
|
|
131
128
|
await removeExtensionFromConfig(configPath, ponytailExtPath, 'manifest-loaded ponytail', { dryRun, verbose });
|
|
132
129
|
await removeExtensionFromConfig(configPath, path.join(extDir, 'shared', 'mode-reinforcement.ts'), 'retired mode reinforcement', { dryRun, verbose });
|
|
133
130
|
const pkgPath = path.join(pluginsDir, 'package.json');
|
|
@@ -146,7 +143,7 @@ async function fixRtk(binDir) {
|
|
|
146
143
|
console.log(' Doctor --fix: repairing RTK binary + wiring');
|
|
147
144
|
const binDest = path.join(binDir, RTK_BINARY_NAME);
|
|
148
145
|
if (dryRun) {
|
|
149
|
-
sayTagged(` [dry-run] would download a checksum-verified rtk to ${binDest} and wire it into
|
|
146
|
+
sayTagged(` [dry-run] would download a checksum-verified rtk to ${binDest} and wire it into the installed hosts`);
|
|
150
147
|
return;
|
|
151
148
|
}
|
|
152
149
|
const spec = rtkPlatformSpec();
|