opencode-system-metrics-tui 0.1.11 → 0.1.13
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 +64 -10
- package/dist/tui.js +252 -46
- package/package.json +3 -2
- package/scripts/uninstall-plugin.ts +202 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## Install globally in OpenCode
|
|
4
4
|
|
|
5
5
|
```powershell
|
|
6
|
-
opencode plugin -g opencode-system-metrics-tui@0.1.
|
|
6
|
+
opencode plugin -g opencode-system-metrics-tui@0.1.13 --force
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
This is the primary installation path. The version must match `package.json`; `--force` asks OpenCode to refresh its package cache. The helper below skips OpenCode when the expected artifact is already current. Verify the actual installed artifact afterward:
|
|
@@ -17,6 +17,19 @@ npm run verify-installation
|
|
|
17
17
|
|
|
18
18
|
Restart OpenCode after installation. This installs the plugin globally for all projects. The OpenCode cache directory name, including `@latest`, is only a package-specifier label and does not prove the package version. Verification reads the nested package's own `package.json` and checks `dist/tui.js`; it reports stale entries without deleting anything.
|
|
19
19
|
|
|
20
|
+
### Uninstall safely
|
|
21
|
+
|
|
22
|
+
This project provides a safe helper; OpenCode does **not** provide a native uninstall command. It defaults to both observed global configuration roots (`~/.config/opencode` and `~/.opencode`), shows a plan, and requires `--yes` before changing anything. It removes only this plugin from OpenCode plugin arrays and verified cache entries. It never removes OpenCode, Node, Bun, shared dependencies, or repository files.
|
|
23
|
+
|
|
24
|
+
```powershell
|
|
25
|
+
npm run uninstall-plugin -- --dry-run
|
|
26
|
+
npm run uninstall-plugin -- --yes
|
|
27
|
+
npm run uninstall-plugin -- --purge-cache --yes # also remove all verified stale versions
|
|
28
|
+
npm run uninstall-plugin -- --local # current project's .opencode config
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Use `--dry-run` first. Without `--yes`, no changes are made. An unversioned package entry removes all verified cache entries for this package; `--purge-cache` explicitly removes all verified entries even when no package entry is configured.
|
|
32
|
+
|
|
20
33
|
OpenCode TUI plugin that adds live CPU, RAM, GPU, GPU VRAM, temperature, and network metrics to the `sidebar_content` slot.
|
|
21
34
|
|
|
22
35
|
The README is available in **Español**, **English**, and **Português**.
|
|
@@ -63,6 +76,19 @@ opencode
|
|
|
63
76
|
|
|
64
77
|
`npm run install-font` ejecuta el instalador compilado incluido en `dist/`, instala **Hack Nerd Font** únicamente para el usuario actual, no se ejecuta automáticamente durante `npm install` y solicita confirmación antes de descargar la fuente oficial.
|
|
65
78
|
|
|
79
|
+
### Desinstalación segura
|
|
80
|
+
|
|
81
|
+
Este proyecto incluye un asistente seguro; OpenCode **no** tiene un comando nativo de desinstalación. Por defecto actúa sobre ambas raíces globales observadas (`~/.config/opencode` y `~/.opencode`), muestra un informe y requiere `--yes` antes de cambiar archivos. Solo elimina este paquete de las listas de plugins y sus entradas de caché verificadas.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm run uninstall-plugin -- --dry-run
|
|
85
|
+
npm run uninstall-plugin -- --yes
|
|
86
|
+
npm run uninstall-plugin -- --purge-cache --yes # también elimina versiones antiguas verificadas
|
|
87
|
+
npm run uninstall-plugin -- --local # configuración .opencode del proyecto actual
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Usa primero `--dry-run`. Sin `--yes` no se realizan cambios. Una entrada de paquete sin versión elimina todas las entradas de caché verificadas de este paquete; `--purge-cache` las elimina explícitamente.
|
|
91
|
+
|
|
66
92
|
Comandos adicionales:
|
|
67
93
|
|
|
68
94
|
```bash
|
|
@@ -94,7 +120,7 @@ npm publish --access public
|
|
|
94
120
|
En otro equipo:
|
|
95
121
|
|
|
96
122
|
```bash
|
|
97
|
-
opencode plugin opencode-system-metrics-tui@0.1.
|
|
123
|
+
opencode plugin opencode-system-metrics-tui@0.1.13
|
|
98
124
|
```
|
|
99
125
|
|
|
100
126
|
El paquete exporta tanto la raíz (`opencode-system-metrics-tui`) como `./tui`; ambas rutas cargan el mismo bundle TUI.
|
|
@@ -104,8 +130,8 @@ El instalador de OpenCode configura el plugin TUI automáticamente. Reinicia Ope
|
|
|
104
130
|
Publicar en npm e instalar en OpenCode son pasos distintos:
|
|
105
131
|
|
|
106
132
|
- `npm publish --access public` publica el paquete en npm.
|
|
107
|
-
- `opencode plugin opencode-system-metrics-tui@0.1.
|
|
108
|
-
- `opencode plugin -g opencode-system-metrics-tui@0.1.
|
|
133
|
+
- `opencode plugin opencode-system-metrics-tui@0.1.13` lo instala solo en el proyecto actual.
|
|
134
|
+
- `opencode plugin -g opencode-system-metrics-tui@0.1.13 --force` lo instala globalmente para todos los proyectos.
|
|
109
135
|
|
|
110
136
|
### Verificación
|
|
111
137
|
|
|
@@ -134,6 +160,8 @@ npm run build
|
|
|
134
160
|
- Windows uses Unicode icons by default (installed-font detection does not change selection); Linux/macOS use Nerd Font only when installed and retain Unicode fallback.
|
|
135
161
|
- On Windows, visible Unicode icons are used by default because OpenCode cannot detect the terminal's active font.
|
|
136
162
|
- Supports Linux, macOS, and Windows when the operating system exposes the metrics.
|
|
163
|
+
- CPU and RAM use native, read-only paths without subprocesses during normal operation: Linux reads `/proc/stat` and `/proc/meminfo`; Windows uses `GetSystemTimes` and `GlobalMemoryStatusEx` through the lazy Koffi integration; macOS uses Mach host statistics and `hw.memsize` on both Intel and Apple Silicon. If native loading or a read fails, the existing `systeminformation` path is used with the existing timeout and cache protections.
|
|
164
|
+
- Native CPU percentages are deltas between consecutive samples; the first sample is intentionally unavailable. Counter resets, malformed data, and API failures fall back safely. Apple Silicon retains unified-memory semantics and does not treat system RAM as discrete VRAM.
|
|
137
165
|
- On macOS, GPU utilization uses `ioreg` and temperatures use the Stats helper when installed; missing helpers leave only that metric unavailable without interrupting the plugin. Intel retains the discrete-GPU `systeminformation` fallback.
|
|
138
166
|
- On Windows, CPU temperature requires LibreHardwareMonitor running as administrator with **Remote Web Server** enabled at `http://127.0.0.1:8085`.
|
|
139
167
|
- On Windows, network speed uses one native `GetIfTable2` call from `iphlpapi.dll` through Koffi; the PowerShell path remains only as a bounded fallback when the native API cannot load.
|
|
@@ -159,6 +187,19 @@ opencode
|
|
|
159
187
|
|
|
160
188
|
`npm run install-font` runs the compiled installer included in `dist/`, installs **Hack Nerd Font** for the current user only, is not executed automatically by `npm install`, and asks for confirmation before downloading the official font.
|
|
161
189
|
|
|
190
|
+
### Safe uninstall
|
|
191
|
+
|
|
192
|
+
This project provides a safe helper; OpenCode does **not** provide a native uninstall command. It targets both observed global configuration roots (`~/.config/opencode` and `~/.opencode`) by default, prints a report, and requires `--yes` before changing anything. It removes only this package from plugin lists and its verified cache entries.
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
npm run uninstall-plugin -- --dry-run
|
|
196
|
+
npm run uninstall-plugin -- --yes
|
|
197
|
+
npm run uninstall-plugin -- --purge-cache --yes # also remove all verified stale versions
|
|
198
|
+
npm run uninstall-plugin -- --local # current project's .opencode config
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Run `--dry-run` first. Without `--yes`, no changes are made. An unversioned package entry removes all verified cache entries for this package; `--purge-cache` explicitly removes all verified entries. Neither mode removes OpenCode, Node, Bun, shared dependencies, or repository files.
|
|
202
|
+
|
|
162
203
|
Extra commands:
|
|
163
204
|
|
|
164
205
|
```bash
|
|
@@ -190,7 +231,7 @@ npm publish --access public
|
|
|
190
231
|
On another machine:
|
|
191
232
|
|
|
192
233
|
```bash
|
|
193
|
-
opencode plugin opencode-system-metrics-tui@0.1.
|
|
234
|
+
opencode plugin opencode-system-metrics-tui@0.1.13
|
|
194
235
|
```
|
|
195
236
|
|
|
196
237
|
The package exports both the root (`opencode-system-metrics-tui`) and `./tui`; both paths load the same TUI bundle.
|
|
@@ -200,8 +241,8 @@ The OpenCode installer configures the TUI plugin automatically. Restart OpenCode
|
|
|
200
241
|
Publishing to npm and installing in OpenCode are separate steps:
|
|
201
242
|
|
|
202
243
|
- `npm publish --access public` publishes the package to npm.
|
|
203
|
-
- `opencode plugin opencode-system-metrics-tui@0.1.
|
|
204
|
-
- `opencode plugin -g opencode-system-metrics-tui@0.1.
|
|
244
|
+
- `opencode plugin opencode-system-metrics-tui@0.1.13` installs it for the current project only.
|
|
245
|
+
- `opencode plugin -g opencode-system-metrics-tui@0.1.13 --force` installs it globally for all projects.
|
|
205
246
|
|
|
206
247
|
### Verification
|
|
207
248
|
|
|
@@ -254,6 +295,19 @@ opencode
|
|
|
254
295
|
|
|
255
296
|
`npm run install-font` executa o instalador compilado incluído em `dist/`, instala a **Hack Nerd Font** somente para o usuário atual, não é executado automaticamente durante `npm install` e pede confirmação antes de baixar a fonte oficial.
|
|
256
297
|
|
|
298
|
+
### Desinstalação segura
|
|
299
|
+
|
|
300
|
+
Este projeto inclui um assistente seguro; o OpenCode **não** oferece um comando nativo de desinstalação. Por padrão, ele usa ambas as raízes globais observadas (`~/.config/opencode` e `~/.opencode`), mostra um relatório e exige `--yes` antes de alterar arquivos. Remove apenas este pacote das listas de plugins e suas entradas de cache verificadas.
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
npm run uninstall-plugin -- --dry-run
|
|
304
|
+
npm run uninstall-plugin -- --yes
|
|
305
|
+
npm run uninstall-plugin -- --purge-cache --yes # também remove versões antigas verificadas
|
|
306
|
+
npm run uninstall-plugin -- --local # configuração .opencode do projeto atual
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Execute primeiro com `--dry-run`. Sem `--yes`, nenhuma alteração é feita. Uma entrada de pacote sem versão remove todas as entradas de cache verificadas deste pacote; `--purge-cache` remove explicitamente todas as entradas verificadas.
|
|
310
|
+
|
|
257
311
|
Comandos adicionais:
|
|
258
312
|
|
|
259
313
|
```bash
|
|
@@ -285,7 +339,7 @@ npm publish --access public
|
|
|
285
339
|
Em outro computador:
|
|
286
340
|
|
|
287
341
|
```bash
|
|
288
|
-
opencode plugin opencode-system-metrics-tui@0.1.
|
|
342
|
+
opencode plugin opencode-system-metrics-tui@0.1.13
|
|
289
343
|
```
|
|
290
344
|
|
|
291
345
|
O pacote exporta tanto a raiz (`opencode-system-metrics-tui`) quanto `./tui`; os dois caminhos carregam o mesmo bundle TUI.
|
|
@@ -295,8 +349,8 @@ O instalador do OpenCode configura o plugin TUI automaticamente. Reinicie o Open
|
|
|
295
349
|
Publicar no npm e instalar no OpenCode são etapas diferentes:
|
|
296
350
|
|
|
297
351
|
- `npm publish --access public` publica o pacote no npm.
|
|
298
|
-
- `opencode plugin opencode-system-metrics-tui@0.1.
|
|
299
|
-
- `opencode plugin -g opencode-system-metrics-tui@0.1.
|
|
352
|
+
- `opencode plugin opencode-system-metrics-tui@0.1.13` instala o plugin apenas no projeto atual.
|
|
353
|
+
- `opencode plugin -g opencode-system-metrics-tui@0.1.13 --force` instala o plugin globalmente para todos os projetos.
|
|
300
354
|
|
|
301
355
|
### Verificação
|
|
302
356
|
|
package/dist/tui.js
CHANGED
|
@@ -6,7 +6,7 @@ import { insertNode as _$insertNode } from "@opentui/solid";
|
|
|
6
6
|
import { insert as _$insert } from "@opentui/solid";
|
|
7
7
|
import { setProp as _$setProp } from "@opentui/solid";
|
|
8
8
|
import { createElement as _$createElement } from "@opentui/solid";
|
|
9
|
-
import { createMemo, createSignal, onCleanup, onMount } from "solid-js";
|
|
9
|
+
import { Show, createMemo, createSignal, onCleanup, onMount } from "solid-js";
|
|
10
10
|
import { TextAttributes } from "@opentui/core";
|
|
11
11
|
|
|
12
12
|
// src/format.ts
|
|
@@ -160,6 +160,189 @@ async function readWindowsNetworkCounters() {
|
|
|
160
160
|
return mapWindowsNetworkRows((await nativeReader)());
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// src/native-metrics.ts
|
|
164
|
+
import { readFile } from "node:fs/promises";
|
|
165
|
+
function parseLinuxCpuCounters(stat) {
|
|
166
|
+
const line = stat.split(/\r?\n/).find((item) => /^cpu(?:\s|$)/.test(item));
|
|
167
|
+
if (line === undefined)
|
|
168
|
+
return;
|
|
169
|
+
const fields = line.trim().split(/\s+/).slice(1);
|
|
170
|
+
if (fields.length < 4)
|
|
171
|
+
return;
|
|
172
|
+
const values = fields.map((value) => {
|
|
173
|
+
try {
|
|
174
|
+
const parsed = BigInt(value);
|
|
175
|
+
return parsed >= 0n ? parsed : undefined;
|
|
176
|
+
} catch {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
if (values.some((value) => value === undefined))
|
|
181
|
+
return;
|
|
182
|
+
const numbers = values;
|
|
183
|
+
return { total: numbers.reduce((sum, value) => sum + value, 0n), idle: numbers[3] + (numbers[4] ?? 0n) };
|
|
184
|
+
}
|
|
185
|
+
function cpuPercentFromCounters(current, previous) {
|
|
186
|
+
if (previous === undefined || current.total < previous.total || current.idle < previous.idle)
|
|
187
|
+
return;
|
|
188
|
+
const totalDelta = current.total - previous.total;
|
|
189
|
+
const idleDelta = current.idle - previous.idle;
|
|
190
|
+
if (totalDelta <= 0n || idleDelta > totalDelta)
|
|
191
|
+
return;
|
|
192
|
+
return Number((totalDelta - idleDelta) * 10000n / totalDelta) / 100;
|
|
193
|
+
}
|
|
194
|
+
function parseLinuxMemoryInfo(meminfo) {
|
|
195
|
+
const values = new Map;
|
|
196
|
+
for (const line of meminfo.split(/\r?\n/)) {
|
|
197
|
+
const match = /^(MemTotal|MemAvailable|MemFree|Buffers|Cached):\s+(\d+)(?:\s+kB)?\s*$/.exec(line);
|
|
198
|
+
if (match !== null)
|
|
199
|
+
values.set(match[1], Number(match[2]) * 1024);
|
|
200
|
+
}
|
|
201
|
+
const total = values.get("MemTotal");
|
|
202
|
+
const hasAvailable = values.has("MemAvailable") || values.has("MemFree") || values.has("Buffers") || values.has("Cached");
|
|
203
|
+
const available = values.get("MemAvailable") ?? (values.get("MemFree") ?? 0) + (values.get("Buffers") ?? 0) + (values.get("Cached") ?? 0);
|
|
204
|
+
if (total === undefined || !hasAvailable || !Number.isSafeInteger(total) || !Number.isSafeInteger(available) || total <= 0 || available < 0)
|
|
205
|
+
return;
|
|
206
|
+
return { totalBytes: total, availableBytes: Math.min(total, available) };
|
|
207
|
+
}
|
|
208
|
+
function mapMemoryBytes(totalBytes, availableBytes) {
|
|
209
|
+
if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(availableBytes) || totalBytes <= 0 || availableBytes < 0)
|
|
210
|
+
return;
|
|
211
|
+
return { memoryTotalBytes: totalBytes, memoryAvailableBytes: Math.min(totalBytes, availableBytes) };
|
|
212
|
+
}
|
|
213
|
+
var reader;
|
|
214
|
+
async function readNativeMetrics() {
|
|
215
|
+
reader ??= (async () => {
|
|
216
|
+
if (process.platform === "linux")
|
|
217
|
+
return createLinuxReader();
|
|
218
|
+
if (process.platform === "win32")
|
|
219
|
+
return createWindowsReader();
|
|
220
|
+
if (process.platform === "darwin")
|
|
221
|
+
return createMacReader();
|
|
222
|
+
throw new Error(`Native CPU/RAM metrics are unsupported on ${process.platform}`);
|
|
223
|
+
})();
|
|
224
|
+
return (await reader)();
|
|
225
|
+
}
|
|
226
|
+
function createLinuxReader() {
|
|
227
|
+
let previous;
|
|
228
|
+
return async () => {
|
|
229
|
+
const [stat, meminfo] = await Promise.all([readFile("/proc/stat", "utf8"), readFile("/proc/meminfo", "utf8")]);
|
|
230
|
+
const counters = parseLinuxCpuCounters(stat);
|
|
231
|
+
const memory = parseLinuxMemoryInfo(meminfo);
|
|
232
|
+
if (counters === undefined || memory === undefined)
|
|
233
|
+
throw new Error("Malformed Linux CPU/RAM data");
|
|
234
|
+
const cpuPercent = cpuPercentFromCounters(counters, previous);
|
|
235
|
+
previous = cpuPercent === undefined && previous !== undefined ? undefined : counters;
|
|
236
|
+
return { cpuPercent, memoryTotalBytes: memory.totalBytes, memoryAvailableBytes: memory.availableBytes };
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async function createWindowsReader() {
|
|
240
|
+
const loaded = await import("koffi");
|
|
241
|
+
const koffi = loaded.default ?? loaded;
|
|
242
|
+
koffi.struct("FILETIME", { low: "uint32", high: "uint32" });
|
|
243
|
+
const memoryStatus = koffi.struct("MEMORYSTATUSEX", {
|
|
244
|
+
length: "uint32",
|
|
245
|
+
memoryLoad: "uint32",
|
|
246
|
+
total: "uint64",
|
|
247
|
+
available: "uint64",
|
|
248
|
+
pageFile: "uint64",
|
|
249
|
+
availablePageFile: "uint64",
|
|
250
|
+
virtual: "uint64",
|
|
251
|
+
availableVirtual: "uint64",
|
|
252
|
+
availableExtended: "uint64"
|
|
253
|
+
});
|
|
254
|
+
const kernel32 = koffi.load("kernel32.dll");
|
|
255
|
+
const getSystemTimes = kernel32.func("int __stdcall GetSystemTimes(_Out_ FILETIME *idle, _Out_ FILETIME *kernel, _Out_ FILETIME *user)");
|
|
256
|
+
const globalMemoryStatusEx = kernel32.func("int __stdcall GlobalMemoryStatusEx(_Inout_ MEMORYSTATUSEX *status)");
|
|
257
|
+
let previous;
|
|
258
|
+
return async () => {
|
|
259
|
+
const idle = {};
|
|
260
|
+
const kernel = {};
|
|
261
|
+
const user = {};
|
|
262
|
+
if (!getSystemTimes(idle, kernel, user))
|
|
263
|
+
throw new Error("GetSystemTimes failed");
|
|
264
|
+
const toUint64 = (value) => BigInt(value.low ?? 0) + (BigInt(value.high ?? 0) << 32n);
|
|
265
|
+
const counters = { total: toUint64(kernel) + toUint64(user), idle: toUint64(idle) };
|
|
266
|
+
const cpuPercent = cpuPercentFromCounters(counters, previous);
|
|
267
|
+
previous = cpuPercent === undefined && previous !== undefined ? undefined : counters;
|
|
268
|
+
const status = { length: koffi.sizeof(memoryStatus) };
|
|
269
|
+
if (!globalMemoryStatusEx(status))
|
|
270
|
+
throw new Error("GlobalMemoryStatusEx failed");
|
|
271
|
+
const memory = mapMemoryBytes(Number(status.total ?? 0n), Number(status.available ?? 0n));
|
|
272
|
+
if (memory === undefined)
|
|
273
|
+
throw new Error("Malformed Windows RAM data");
|
|
274
|
+
return { cpuPercent, ...memory };
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
async function createMacReader() {
|
|
278
|
+
const loaded = await import("koffi");
|
|
279
|
+
const koffi = loaded.default ?? loaded;
|
|
280
|
+
const vmStatistics = koffi.struct("vm_statistics64", {
|
|
281
|
+
free_count: "uint32",
|
|
282
|
+
active_count: "uint32",
|
|
283
|
+
inactive_count: "uint32",
|
|
284
|
+
wire_count: "uint32",
|
|
285
|
+
zero_fill_count: "uint32",
|
|
286
|
+
reactivations: "uint32",
|
|
287
|
+
pageins: "uint32",
|
|
288
|
+
pageouts: "uint32",
|
|
289
|
+
faults: "uint32",
|
|
290
|
+
cow_faults: "uint32",
|
|
291
|
+
lookups: "uint32",
|
|
292
|
+
hits: "uint32",
|
|
293
|
+
purgeable_count: "uint32",
|
|
294
|
+
speculative_count: "uint32",
|
|
295
|
+
decompressions: "uint32",
|
|
296
|
+
compressions: "uint32",
|
|
297
|
+
swapins: "uint32",
|
|
298
|
+
swapouts: "uint32",
|
|
299
|
+
compressor_page_count: "uint32",
|
|
300
|
+
throttled_count: "uint32",
|
|
301
|
+
external_page_count: "uint32",
|
|
302
|
+
internal_page_count: "uint32",
|
|
303
|
+
total_uncompressed_pages_in_compressor: "uint32"
|
|
304
|
+
});
|
|
305
|
+
const cpuLoad = koffi.struct("host_cpu_load_info", { user: "uint32", system: "uint32", idle: "uint32", nice: "uint32" });
|
|
306
|
+
const libc = koffi.load("libSystem.B.dylib");
|
|
307
|
+
const sysctlbyname = libc.func("int sysctlbyname(const char *name, void *oldp, size_t *oldlenp, const void *newp, size_t newlen)");
|
|
308
|
+
const machHostSelf = libc.func("uint32 mach_host_self()");
|
|
309
|
+
const hostStatistics64 = libc.func("int host_statistics64(uint32 host, int flavor, _Out_ vm_statistics64 *stats, _Inout_ uint32_t *count)");
|
|
310
|
+
const hostCpuLoad = libc.func("int host_statistics(uint32 host, int flavor, _Out_ host_cpu_load_info *load, _Inout_ uint32_t *count)");
|
|
311
|
+
const readUint64Sysctl = (name) => {
|
|
312
|
+
const output = koffi.alloc("uint64", 1);
|
|
313
|
+
const length = [8];
|
|
314
|
+
if (sysctlbyname(name, output, length, null, 0) !== 0)
|
|
315
|
+
throw new Error(`sysctlbyname failed: ${name}`);
|
|
316
|
+
return Number(koffi.decode(output, "uint64"));
|
|
317
|
+
};
|
|
318
|
+
const totalBytes = readUint64Sysctl("hw.memsize");
|
|
319
|
+
const pageSize = readUint64Sysctl("hw.pagesize");
|
|
320
|
+
if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(pageSize) || pageSize <= 0)
|
|
321
|
+
throw new Error("Malformed macOS memory data");
|
|
322
|
+
let previous;
|
|
323
|
+
return async () => {
|
|
324
|
+
const load = {};
|
|
325
|
+
const loadCount = [4];
|
|
326
|
+
if (hostCpuLoad(machHostSelf(), 3, load, loadCount) !== 0)
|
|
327
|
+
throw new Error("host_statistics failed");
|
|
328
|
+
const counters = {
|
|
329
|
+
total: BigInt(load.user ?? 0) + BigInt(load.system ?? 0) + BigInt(load.idle ?? 0) + BigInt(load.nice ?? 0),
|
|
330
|
+
idle: BigInt(load.idle ?? 0)
|
|
331
|
+
};
|
|
332
|
+
const cpuPercent = cpuPercentFromCounters(counters, previous);
|
|
333
|
+
previous = cpuPercent === undefined && previous !== undefined ? undefined : counters;
|
|
334
|
+
const stats = {};
|
|
335
|
+
const count = [38];
|
|
336
|
+
if (hostStatistics64(machHostSelf(), 4, stats, count) !== 0)
|
|
337
|
+
throw new Error("host_statistics64 failed");
|
|
338
|
+
const availablePages = (stats.free_count ?? 0) + (stats.inactive_count ?? 0) + (stats.purgeable_count ?? 0) + (stats.speculative_count ?? 0);
|
|
339
|
+
const memory = mapMemoryBytes(totalBytes, availablePages * pageSize);
|
|
340
|
+
if (memory === undefined)
|
|
341
|
+
throw new Error("Malformed macOS RAM data");
|
|
342
|
+
return { cpuPercent, ...memory };
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
163
346
|
// src/metrics.ts
|
|
164
347
|
var execFileAsync = promisify(execFile);
|
|
165
348
|
var DEFAULT_METRIC_TIMEOUT_MS = 1500;
|
|
@@ -451,21 +634,31 @@ async function readWindowsCpuTemperature() {
|
|
|
451
634
|
return value === undefined ? undefined : { main: value };
|
|
452
635
|
}
|
|
453
636
|
async function readMetrics() {
|
|
454
|
-
const [
|
|
455
|
-
withTimeout(
|
|
456
|
-
withTimeout(si.mem(), isWindows ? WINDOWS_MEMORY_TIMEOUT_MS : DEFAULT_METRIC_TIMEOUT_MS),
|
|
637
|
+
const [native, hardwareArchitecture] = await Promise.allSettled([
|
|
638
|
+
withTimeout(readNativeMetrics(), isWindows ? WINDOWS_MEMORY_TIMEOUT_MS : DEFAULT_METRIC_TIMEOUT_MS),
|
|
457
639
|
readMacHardwareArchitecture()
|
|
458
640
|
]);
|
|
641
|
+
let cpuPercent = native.status === "fulfilled" ? finite(native.value.cpuPercent) : null;
|
|
642
|
+
let memoryTotalBytes = native.status === "fulfilled" ? finite(native.value.memoryTotalBytes) : null;
|
|
643
|
+
let memoryAvailableBytes = native.status === "fulfilled" ? finite(native.value.memoryAvailableBytes) : null;
|
|
644
|
+
let fallbackMemoryUsedBytes = null;
|
|
645
|
+
if (native.status !== "fulfilled") {
|
|
646
|
+
const [load, memory] = await Promise.allSettled([
|
|
647
|
+
withTimeout(si.currentLoad(), DEFAULT_METRIC_TIMEOUT_MS),
|
|
648
|
+
withTimeout(si.mem(), isWindows ? WINDOWS_MEMORY_TIMEOUT_MS : DEFAULT_METRIC_TIMEOUT_MS)
|
|
649
|
+
]);
|
|
650
|
+
cpuPercent = load.status === "fulfilled" ? finite(load.value.currentLoad) : null;
|
|
651
|
+
memoryTotalBytes = memory.status === "fulfilled" ? finite(memory.value.total) : null;
|
|
652
|
+
memoryAvailableBytes = memory.status === "fulfilled" ? finite(memory.value.available) : null;
|
|
653
|
+
fallbackMemoryUsedBytes = memoryTotalBytes !== null && memoryAvailableBytes !== null ? Math.max(0, memoryTotalBytes - memoryAvailableBytes) : memory.status === "fulfilled" ? finite(memory.value.used) : null;
|
|
654
|
+
}
|
|
459
655
|
const [temperature, graphics, network, mac] = await Promise.allSettled([
|
|
460
656
|
readCachedMetric(cachedCpuTemperature, isWindows ? readWindowsCpuTemperature : () => si.cpuTemperature(), TEMPERATURE_CACHE_MS, Date.now(), TEMPERATURE_CACHE_MS, 2500),
|
|
461
657
|
readCachedMetric(cachedGraphics, () => si.graphics(), SLOW_METRIC_CACHE_MS, Date.now(), SLOW_METRIC_CACHE_MS, 2500),
|
|
462
658
|
readCachedMetric(cachedNetwork, isWindows ? readWindowsNetworkMetrics : () => si.networkStats("*"), isWindows ? NETWORK_CACHE_MS : 2000, Date.now(), OPTIONAL_SOURCE_BACKOFF_MS, isWindows ? WINDOWS_NETWORK_OUTER_TIMEOUT_MS : DEFAULT_METRIC_TIMEOUT_MS),
|
|
463
659
|
isMac ? readCachedMetric(cachedMacMetrics, () => readMacMetrics(), SLOW_METRIC_CACHE_MS, Date.now(), OPTIONAL_SOURCE_BACKOFF_MS, MAC_METRIC_TIMEOUT_MS) : Promise.resolve(undefined)
|
|
464
660
|
]);
|
|
465
|
-
const
|
|
466
|
-
const memoryTotalBytes = memory.status === "fulfilled" ? finite(memory.value.total) : null;
|
|
467
|
-
const memoryAvailableBytes = memory.status === "fulfilled" ? finite(memory.value.available) : null;
|
|
468
|
-
const memoryUsedBytes = memoryTotalBytes !== null && memoryAvailableBytes !== null ? Math.max(0, memoryTotalBytes - memoryAvailableBytes) : memory.status === "fulfilled" ? finite(memory.value.used) : null;
|
|
661
|
+
const memoryUsedBytes = fallbackMemoryUsedBytes ?? (memoryTotalBytes !== null && memoryAvailableBytes !== null ? Math.max(0, memoryTotalBytes - memoryAvailableBytes) : null);
|
|
469
662
|
const memoryPercent = memoryUsedBytes !== null && memoryTotalBytes !== null && memoryTotalBytes > 0 ? memoryUsedBytes / memoryTotalBytes * 100 : null;
|
|
470
663
|
const gpu = selectGpuMetrics(graphics.status === "fulfilled" ? graphics.value : undefined);
|
|
471
664
|
const macMetrics = mac.status === "fulfilled" ? mac.value : undefined;
|
|
@@ -657,12 +850,11 @@ function MetricsPanel(props) {
|
|
|
657
850
|
};
|
|
658
851
|
});
|
|
659
852
|
return (() => {
|
|
660
|
-
var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createTextNode(` `), _el$4 = _$createElement("box"), _el$5 = _$createElement("text"), _el$6 = _$createElement("text"), _el$8 = _$createElement("text"), _el$9 = _$createTextNode(` · `), _el$0 = _$createTextNode(` `), _el$1 = _$createElement("box"), _el$10 = _$createElement("text"), _el$11 = _$createElement("text"), _el$13 = _$createElement("text"), _el$14 = _$createTextNode(` / `), _el$15 = _$createTextNode(` (`), _el$16 = _$createTextNode(`)`), _el$17 = _$createElement("box"), _el$18 = _$createElement("text"), _el$19 = _$createElement("text"), _el$21 = _$createElement("text"), _el$22 = _$createTextNode(` · `), _el$23 = _$createTextNode(` `), _el$
|
|
853
|
+
var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createTextNode(` `), _el$4 = _$createElement("box"), _el$5 = _$createElement("text"), _el$6 = _$createElement("text"), _el$8 = _$createElement("text"), _el$9 = _$createTextNode(` · `), _el$0 = _$createTextNode(` `), _el$1 = _$createElement("box"), _el$10 = _$createElement("text"), _el$11 = _$createElement("text"), _el$13 = _$createElement("text"), _el$14 = _$createTextNode(` / `), _el$15 = _$createTextNode(` (`), _el$16 = _$createTextNode(`)`), _el$17 = _$createElement("box"), _el$18 = _$createElement("text"), _el$19 = _$createElement("text"), _el$21 = _$createElement("text"), _el$22 = _$createTextNode(` · `), _el$23 = _$createTextNode(` `), _el$33 = _$createElement("box"), _el$34 = _$createElement("text"), _el$35 = _$createElement("text"), _el$37 = _$createElement("text"), _el$38 = _$createTextNode(`↓ `), _el$39 = _$createTextNode(` ↑ `);
|
|
661
854
|
_$insertNode(_el$, _el$2);
|
|
662
855
|
_$insertNode(_el$, _el$4);
|
|
663
856
|
_$insertNode(_el$, _el$1);
|
|
664
857
|
_$insertNode(_el$, _el$17);
|
|
665
|
-
_$insertNode(_el$, _el$24);
|
|
666
858
|
_$insertNode(_el$, _el$33);
|
|
667
859
|
_$setProp(_el$, "flexDirection", "column");
|
|
668
860
|
_$setProp(_el$, "paddingLeft", 0);
|
|
@@ -704,20 +896,40 @@ function MetricsPanel(props) {
|
|
|
704
896
|
_$insert(_el$21, () => formatPercent(metricsState.sharedMetrics().gpuPercent), _el$22);
|
|
705
897
|
_$insert(_el$21, () => icons.thermometer, _el$23);
|
|
706
898
|
_$insert(_el$21, () => formatTemperature(metricsState.sharedMetrics().gpuTemperatureCelsius), null);
|
|
707
|
-
_$
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
899
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
900
|
+
get when() {
|
|
901
|
+
return metricsState.sharedMetrics().gpuMemoryIsUnified !== true;
|
|
902
|
+
},
|
|
903
|
+
get children() {
|
|
904
|
+
var _el$24 = _$createElement("box"), _el$25 = _$createElement("text"), _el$26 = _$createElement("text"), _el$27 = _$createTextNode(` `), _el$28 = _$createTextNode(` `), _el$29 = _$createElement("text"), _el$30 = _$createTextNode(` / `), _el$31 = _$createTextNode(` (`), _el$32 = _$createTextNode(`)`);
|
|
905
|
+
_$insertNode(_el$24, _el$25);
|
|
906
|
+
_$insertNode(_el$24, _el$26);
|
|
907
|
+
_$insertNode(_el$24, _el$29);
|
|
908
|
+
_$setProp(_el$24, "flexDirection", "row");
|
|
909
|
+
_$insert(_el$25, () => gpuMemory().icon);
|
|
910
|
+
_$insertNode(_el$26, _el$27);
|
|
911
|
+
_$insertNode(_el$26, _el$28);
|
|
912
|
+
_$insert(_el$26, () => gpuMemory().label, _el$28);
|
|
913
|
+
_$insertNode(_el$29, _el$30);
|
|
914
|
+
_$insertNode(_el$29, _el$31);
|
|
915
|
+
_$insertNode(_el$29, _el$32);
|
|
916
|
+
_$insert(_el$29, () => formatGiB(gpuMemory().usedBytes), _el$30);
|
|
917
|
+
_$insert(_el$29, () => formatGiB(gpuMemory().totalBytes), _el$31);
|
|
918
|
+
_$insert(_el$29, () => formatPercent(gpuMemory().percent), _el$32);
|
|
919
|
+
_$effect((_p$) => {
|
|
920
|
+
var _v$ = props.theme.success, _v$2 = props.theme.text, _v$3 = props.theme.textMuted;
|
|
921
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$25, "fg", _v$, _p$.e));
|
|
922
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$26, "fg", _v$2, _p$.t));
|
|
923
|
+
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$29, "fg", _v$3, _p$.a));
|
|
924
|
+
return _p$;
|
|
925
|
+
}, {
|
|
926
|
+
e: undefined,
|
|
927
|
+
t: undefined,
|
|
928
|
+
a: undefined
|
|
929
|
+
});
|
|
930
|
+
return _el$24;
|
|
931
|
+
}
|
|
932
|
+
}), _el$33);
|
|
721
933
|
_$insertNode(_el$33, _el$34);
|
|
722
934
|
_$insertNode(_el$33, _el$35);
|
|
723
935
|
_$insertNode(_el$33, _el$37);
|
|
@@ -729,24 +941,21 @@ function MetricsPanel(props) {
|
|
|
729
941
|
_$insert(_el$37, () => formatRate(metricsState.sharedMetrics().downloadBytesPerSecond), _el$39);
|
|
730
942
|
_$insert(_el$37, () => formatRate(metricsState.sharedMetrics().uploadBytesPerSecond), null);
|
|
731
943
|
_$effect((_p$) => {
|
|
732
|
-
var _v$ = props.theme.text, _v$
|
|
733
|
-
_v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v
|
|
734
|
-
_v$
|
|
735
|
-
_v$
|
|
736
|
-
_v$
|
|
737
|
-
_v$
|
|
738
|
-
_v$
|
|
739
|
-
_v$
|
|
740
|
-
_v$
|
|
741
|
-
_v$
|
|
742
|
-
_v$
|
|
743
|
-
_v$
|
|
744
|
-
_v$
|
|
745
|
-
_v$
|
|
746
|
-
_v$
|
|
747
|
-
_v$13 !== _p$.m && (_p$.m = _$setProp(_el$34, "fg", _v$13, _p$.m));
|
|
748
|
-
_v$14 !== _p$.f && (_p$.f = _$setProp(_el$35, "fg", _v$14, _p$.f));
|
|
749
|
-
_v$15 !== _p$.y && (_p$.y = _$setProp(_el$37, "fg", _v$15, _p$.y));
|
|
944
|
+
var _v$4 = props.theme.text, _v$5 = TextAttributes.BOLD, _v$6 = props.theme.success, _v$7 = props.theme.text, _v$8 = props.theme.textMuted, _v$9 = props.theme.success, _v$0 = props.theme.text, _v$1 = props.theme.textMuted, _v$10 = props.theme.success, _v$11 = props.theme.text, _v$12 = props.theme.textMuted, _v$13 = props.theme.success, _v$14 = props.theme.text, _v$15 = props.theme.textMuted;
|
|
945
|
+
_v$4 !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$4, _p$.e));
|
|
946
|
+
_v$5 !== _p$.t && (_p$.t = _$setProp(_el$2, "attributes", _v$5, _p$.t));
|
|
947
|
+
_v$6 !== _p$.a && (_p$.a = _$setProp(_el$5, "fg", _v$6, _p$.a));
|
|
948
|
+
_v$7 !== _p$.o && (_p$.o = _$setProp(_el$6, "fg", _v$7, _p$.o));
|
|
949
|
+
_v$8 !== _p$.i && (_p$.i = _$setProp(_el$8, "fg", _v$8, _p$.i));
|
|
950
|
+
_v$9 !== _p$.n && (_p$.n = _$setProp(_el$10, "fg", _v$9, _p$.n));
|
|
951
|
+
_v$0 !== _p$.s && (_p$.s = _$setProp(_el$11, "fg", _v$0, _p$.s));
|
|
952
|
+
_v$1 !== _p$.h && (_p$.h = _$setProp(_el$13, "fg", _v$1, _p$.h));
|
|
953
|
+
_v$10 !== _p$.r && (_p$.r = _$setProp(_el$18, "fg", _v$10, _p$.r));
|
|
954
|
+
_v$11 !== _p$.d && (_p$.d = _$setProp(_el$19, "fg", _v$11, _p$.d));
|
|
955
|
+
_v$12 !== _p$.l && (_p$.l = _$setProp(_el$21, "fg", _v$12, _p$.l));
|
|
956
|
+
_v$13 !== _p$.u && (_p$.u = _$setProp(_el$34, "fg", _v$13, _p$.u));
|
|
957
|
+
_v$14 !== _p$.c && (_p$.c = _$setProp(_el$35, "fg", _v$14, _p$.c));
|
|
958
|
+
_v$15 !== _p$.w && (_p$.w = _$setProp(_el$37, "fg", _v$15, _p$.w));
|
|
750
959
|
return _p$;
|
|
751
960
|
}, {
|
|
752
961
|
e: undefined,
|
|
@@ -762,10 +971,7 @@ function MetricsPanel(props) {
|
|
|
762
971
|
l: undefined,
|
|
763
972
|
u: undefined,
|
|
764
973
|
c: undefined,
|
|
765
|
-
w: undefined
|
|
766
|
-
m: undefined,
|
|
767
|
-
f: undefined,
|
|
768
|
-
y: undefined
|
|
974
|
+
w: undefined
|
|
769
975
|
});
|
|
770
976
|
return _el$;
|
|
771
977
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-system-metrics-tui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "OpenCode TUI sidebar plugin for live system metrics",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"typecheck": "tsc --noEmit",
|
|
26
26
|
"install-font": "node dist/install-hack-font.js",
|
|
27
27
|
"verify-installation": "tsx scripts/plugin-installation.ts",
|
|
28
|
-
"install-plugin": "tsx scripts/install-plugin.ts"
|
|
28
|
+
"install-plugin": "tsx scripts/install-plugin.ts",
|
|
29
|
+
"uninstall-plugin": "tsx scripts/uninstall-plugin.ts"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
|
31
32
|
"node": ">=18"
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
|
|
6
|
+
export type UninstallScope = "global" | "local"
|
|
7
|
+
|
|
8
|
+
export type ConfigChange = {
|
|
9
|
+
path: string
|
|
10
|
+
removed: string[]
|
|
11
|
+
content: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type CacheCandidate = {
|
|
15
|
+
path: string
|
|
16
|
+
version: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type UninstallPlan = {
|
|
20
|
+
configChanges: ConfigChange[]
|
|
21
|
+
cacheCandidates: CacheCandidate[]
|
|
22
|
+
warnings: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type PackageManifest = { name?: unknown; version?: unknown }
|
|
26
|
+
|
|
27
|
+
const packageManifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { name: string }
|
|
28
|
+
const packageName = packageManifest.name
|
|
29
|
+
|
|
30
|
+
function isPackageSpec(value: unknown, name: string): value is string {
|
|
31
|
+
return typeof value === "string" && (value === name || value.startsWith(`${name}@`))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function filterPluginArrays(value: unknown, name: string, removed: string[]): unknown {
|
|
35
|
+
if (Array.isArray(value)) return value
|
|
36
|
+
if (value === null || typeof value !== "object") return value
|
|
37
|
+
|
|
38
|
+
const result: Record<string, unknown> = {}
|
|
39
|
+
for (const [key, child] of Object.entries(value)) {
|
|
40
|
+
if ((key === "plugin" || key === "plugins") && Array.isArray(child)) {
|
|
41
|
+
result[key] = child.filter((entry) => {
|
|
42
|
+
const spec = typeof entry === "string" ? entry : Array.isArray(entry) ? entry[0] : undefined
|
|
43
|
+
if (!isPackageSpec(spec, name)) return true
|
|
44
|
+
removed.push(spec)
|
|
45
|
+
return false
|
|
46
|
+
})
|
|
47
|
+
} else {
|
|
48
|
+
result[key] = filterPluginArrays(child, name, removed)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function filterPluginConfig(content: string, name = packageName): { content: string; removed: string[] } {
|
|
55
|
+
const parsed: unknown = JSON.parse(content)
|
|
56
|
+
const removed: string[] = []
|
|
57
|
+
const filtered = filterPluginArrays(parsed, name, removed)
|
|
58
|
+
const newline = content.includes("\r\n") ? "\r\n" : "\n"
|
|
59
|
+
const serialized = JSON.stringify(filtered, null, 2)
|
|
60
|
+
return { content: `${serialized.replaceAll("\n", newline)}${newline}`, removed }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getConfigPaths(scope: UninstallScope, cwd = process.cwd(), home = homedir(), env: NodeJS.ProcessEnv = process.env): string[] {
|
|
64
|
+
if (scope === "local") return [join(cwd, ".opencode", "tui.json"), join(cwd, ".opencode", "opencode.json"), join(cwd, "opencode.json")]
|
|
65
|
+
const configRoot = env.XDG_CONFIG_HOME ?? join(home, ".config")
|
|
66
|
+
const globalRoots = [join(configRoot, "opencode"), join(home, ".config", "opencode"), join(home, ".opencode")]
|
|
67
|
+
return [...new Set([
|
|
68
|
+
...globalRoots.flatMap((root) => [join(root, "tui.json"), join(root, "opencode.json")]),
|
|
69
|
+
join(configRoot, "opencode.json"),
|
|
70
|
+
])]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getCachePackagesRoot(home = homedir(), env: NodeJS.ProcessEnv = process.env): string {
|
|
74
|
+
const cacheRoot = env.OPENCODE_CACHE_DIR ?? env.XDG_CACHE_HOME ?? join(home, ".cache")
|
|
75
|
+
return join(cacheRoot, "opencode", "packages")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
79
|
+
const fromRoot = relative(resolve(root), resolve(candidate))
|
|
80
|
+
return fromRoot !== "" && !fromRoot.startsWith("..") && !isAbsolute(fromRoot)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readPackageManifest(path: string): Promise<PackageManifest | undefined> {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(await readFile(path, "utf8")) as PackageManifest
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function findCacheCandidates(
|
|
92
|
+
cacheRoot: string,
|
|
93
|
+
name = packageName,
|
|
94
|
+
versions: Set<string> | undefined = undefined,
|
|
95
|
+
purgeCache = false,
|
|
96
|
+
): Promise<{ candidates: CacheCandidate[]; warnings: string[] }> {
|
|
97
|
+
const root = resolve(cacheRoot)
|
|
98
|
+
const candidates: CacheCandidate[] = []
|
|
99
|
+
const warnings: string[] = []
|
|
100
|
+
let entries
|
|
101
|
+
try {
|
|
102
|
+
if ((await lstat(root)).isSymbolicLink()) throw new Error(`Unexpected symbolic link cache root: ${root}`)
|
|
103
|
+
entries = await readdir(root, { withFileTypes: true })
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { candidates, warnings }
|
|
106
|
+
if (error instanceof Error && error.message.startsWith("Unexpected symbolic link cache root:")) throw error
|
|
107
|
+
throw new Error(`Cannot inspect OpenCode cache root: ${root}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (!entry.isDirectory() || !entry.name.startsWith(`${name}@`)) continue
|
|
112
|
+
const candidatePath = join(root, entry.name)
|
|
113
|
+
if (!isWithin(root, candidatePath) || basename(candidatePath) !== entry.name) throw new Error(`Unexpected cache path: ${candidatePath}`)
|
|
114
|
+
const manifest = await readPackageManifest(join(candidatePath, "node_modules", name, "package.json"))
|
|
115
|
+
if (manifest?.name !== name || typeof manifest.version !== "string") {
|
|
116
|
+
warnings.push(`Skipped unverified cache entry: ${candidatePath}`)
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (purgeCache || versions?.has(manifest.version)) candidates.push({ path: candidatePath, version: manifest.version })
|
|
120
|
+
}
|
|
121
|
+
candidates.sort((left, right) => left.path.localeCompare(right.path))
|
|
122
|
+
return { candidates, warnings }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function buildUninstallPlan(options: {
|
|
126
|
+
scope: UninstallScope
|
|
127
|
+
configPaths?: string[]
|
|
128
|
+
cacheRoot?: string
|
|
129
|
+
packageName?: string
|
|
130
|
+
purgeCache?: boolean
|
|
131
|
+
}): Promise<UninstallPlan> {
|
|
132
|
+
const name = options.packageName ?? packageName
|
|
133
|
+
const configChanges: ConfigChange[] = []
|
|
134
|
+
const versions = new Set<string>()
|
|
135
|
+
const warnings: string[] = []
|
|
136
|
+
let hasUnversionedPackage = false
|
|
137
|
+
|
|
138
|
+
for (const path of options.configPaths ?? getConfigPaths(options.scope)) {
|
|
139
|
+
let content: string
|
|
140
|
+
try {
|
|
141
|
+
if ((await lstat(path)).isSymbolicLink()) throw new Error(`Unexpected symbolic link config path: ${path}`)
|
|
142
|
+
content = await readFile(path, "utf8")
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue
|
|
146
|
+
throw new Error(`Cannot read OpenCode config: ${path}`)
|
|
147
|
+
}
|
|
148
|
+
let filtered: { content: string; removed: string[] }
|
|
149
|
+
try { filtered = filterPluginConfig(content, name) }
|
|
150
|
+
catch { throw new Error(`Malformed OpenCode config: ${path}`) }
|
|
151
|
+
if (filtered.removed.length === 0) continue
|
|
152
|
+
for (const spec of filtered.removed) {
|
|
153
|
+
if (spec === name) hasUnversionedPackage = true
|
|
154
|
+
const version = spec.slice(name.length + 1)
|
|
155
|
+
if (version && version !== "latest") versions.add(version)
|
|
156
|
+
}
|
|
157
|
+
configChanges.push({ path, removed: filtered.removed, content: filtered.content })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const cache = await findCacheCandidates(options.cacheRoot ?? getCachePackagesRoot(), name, versions, options.purgeCache || hasUnversionedPackage)
|
|
161
|
+
return { configChanges, cacheCandidates: cache.candidates, warnings: [...warnings, ...cache.warnings] }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function printPlan(plan: UninstallPlan, dryRun: boolean): void {
|
|
165
|
+
console.log(dryRun ? "Uninstall dry run (no changes will be made):" : "Uninstall plan:")
|
|
166
|
+
for (const change of plan.configChanges) console.log(`Config ${dryRun ? "would remove" : "remove"} ${change.removed.join(", ")} from ${change.path}`)
|
|
167
|
+
for (const candidate of plan.cacheCandidates) console.log(`Cache ${dryRun ? "would remove" : "remove"} ${candidate.path} (${candidate.version})`)
|
|
168
|
+
if (plan.configChanges.length === 0 && plan.cacheCandidates.length === 0) console.log("No matching plugin configuration or verified cache entries found.")
|
|
169
|
+
for (const warning of plan.warnings) console.warn(`Warning: ${warning}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function uninstallPlugin(args = process.argv.slice(2)): Promise<void> {
|
|
173
|
+
const scope: UninstallScope = args.includes("--local") ? "local" : "global"
|
|
174
|
+
if (args.includes("--local") && args.includes("--global")) throw new Error("Choose only one scope: --local or --global")
|
|
175
|
+
const purgeCache = args.includes("--purge-cache")
|
|
176
|
+
const dryRun = args.includes("--dry-run") || args.includes("--report")
|
|
177
|
+
const yes = args.includes("--yes")
|
|
178
|
+
const plan = await buildUninstallPlan({ scope, purgeCache })
|
|
179
|
+
printPlan(plan, dryRun)
|
|
180
|
+
if (dryRun || (plan.configChanges.length === 0 && plan.cacheCandidates.length === 0)) return
|
|
181
|
+
if (!yes) {
|
|
182
|
+
console.log("Destructive uninstall requires --yes; no changes were made.")
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const change of plan.configChanges) {
|
|
187
|
+
if (!isAbsolute(change.path) || (await lstat(change.path)).isSymbolicLink()) throw new Error(`Unexpected config path: ${change.path}`)
|
|
188
|
+
const mode = (await stat(change.path)).mode
|
|
189
|
+
await writeFile(change.path, change.content, { mode })
|
|
190
|
+
}
|
|
191
|
+
const cacheRoot = resolve(getCachePackagesRoot())
|
|
192
|
+
for (const candidate of plan.cacheCandidates) {
|
|
193
|
+
if (!isWithin(cacheRoot, candidate.path) || !basename(candidate.path).startsWith(`${packageName}@`) || (await lstat(candidate.path)).isSymbolicLink()) throw new Error(`Unexpected cache path: ${candidate.path}`)
|
|
194
|
+
await rm(candidate.path, { recursive: true, force: false })
|
|
195
|
+
}
|
|
196
|
+
console.log("Uninstall completed. Restart OpenCode if it was running.")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
|
200
|
+
try { await uninstallPlugin() }
|
|
201
|
+
catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 }
|
|
202
|
+
}
|