oh-langfuse 0.1.31 → 0.1.32

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `oh-langfuse` 是用于给 Claude Code、OpenCode 和 Codex 配置 Langfuse 追踪的命令行工具。它提供交互式安装向导,也支持 `setup` / `check` 直接命令,方便在用户机器上安装、修复和校验配置。
4
4
 
5
- 当前 npm 版本:`0.1.25`
5
+ 当前 npm 版本:`0.1.32`
6
6
 
7
7
  ## 能做什么
8
8
 
@@ -34,6 +34,23 @@ npx oh-langfuse@latest check opencode
34
34
  npx oh-langfuse@latest check codex
35
35
  ```
36
36
 
37
+ ## 自动更新
38
+
39
+ 安装或更新 runtime 后,工具会在 `~/.config/oh-langfuse/runtime.json` 记录 Claude Code / OpenCode / Codex 当前写入本机 runtime 的 `oh-langfuse` 版本。
40
+
41
+ OpenCode 生成的 `launch-opencode-langfuse.*` 会在启动 agent 前执行一次更新检测:如果 npm 上的 `oh-langfuse@latest` 高于本机 runtime 版本,会提示是否更新;用户确认后才会运行 `npx oh-langfuse@latest update opencode`。Claude Code 和 Codex 也会生成对应 launcher:
42
+
43
+ - `~/.claude/launch-claude-langfuse.cmd` / `~/.claude/launch-claude-langfuse.sh`
44
+ - `~/.codex/launch-codex-langfuse.cmd` / `~/.codex/launch-codex-langfuse.sh`
45
+
46
+ 如果直接运行系统原始的 `claude` / `codex` 命令,工具不会强行替换该命令;需要使用上述 launcher 才能做到启动前检测并提示更新。也可以手动运行:
47
+
48
+ ```bash
49
+ npx oh-langfuse@latest auto-update opencode
50
+ npx oh-langfuse@latest auto-update claude
51
+ npx oh-langfuse@latest auto-update codex
52
+ ```
53
+
37
54
  本地开发运行:
38
55
 
39
56
  ```bash
package/bin/cli.js CHANGED
@@ -796,7 +796,8 @@ function printHelp() {
796
796
  "oh-langfuse update all",
797
797
  "oh-langfuse update claude",
798
798
  "oh-langfuse update opencode",
799
- "oh-langfuse update codex"
799
+ "oh-langfuse update codex",
800
+ "oh-langfuse auto-update opencode"
800
801
  ]);
801
802
  renderSection("Options", [
802
803
  `${paint("--dry-run", t.gold)} Preview actions without writing files or installing packages.`,
@@ -856,6 +857,15 @@ async function main() {
856
857
  ];
857
858
  return runNodeScript("update-langfuse-runtime.mjs", updateArgs, options);
858
859
  }
860
+ if (cmd === "auto-update") {
861
+ return runNodeScript("auto-update-runtime.mjs", [
862
+ target || "all",
863
+ ...(hasValue(options.npmRegistry) ? [`--npmRegistry=${options.npmRegistry}`] : []),
864
+ ...(hasValue(options.pipIndexUrl) ? [`--pipIndexUrl=${options.pipIndexUrl}`] : []),
865
+ ...(options.skipCheck ? ["--skip-check"] : []),
866
+ ...(options.yes ? ["--yes"] : []),
867
+ ], options);
868
+ }
859
869
  if (cmd === "check" && target === "claude") return checkClaude(options);
860
870
  if (cmd === "check" && target === "opencode") return checkOpenCode(options);
861
871
  if (cmd === "check" && target === "codex") return checkCodex(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-langfuse",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
@@ -12,7 +12,8 @@
12
12
  "code-tool-langfuse": "bin/cli.js"
13
13
  },
14
14
  "files": [
15
- "bin",
15
+ "bin",
16
+ "scripts/auto-update-runtime.mjs",
16
17
  "scripts/codex-langfuse-check.mjs",
17
18
  "scripts/codex-langfuse-setup.mjs",
18
19
  "scripts/json-utils.mjs",
@@ -25,6 +26,7 @@
25
26
  "scripts/real-self-verify.mjs",
26
27
  "scripts/log-filter-utils.mjs",
27
28
  "scripts/metrics-utils.mjs",
29
+ "scripts/runtime-state-utils.mjs",
28
30
  "scripts/update-langfuse-runtime.mjs",
29
31
  "scripts/update-utils.mjs",
30
32
  "langfuse_hook.py",
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { extractVersionFromNpmMetadata, isNewerVersion } from "./update-utils.mjs";
7
+ import { getRuntimeInstallRecord } from "./runtime-state-utils.mjs";
8
+
9
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
11
+ const ALLOWED_TARGETS = new Set(["claude", "opencode", "codex"]);
12
+
13
+ function parseArgs(argv) {
14
+ const args = { _: [] };
15
+ for (const raw of argv) {
16
+ if (!raw.startsWith("--")) {
17
+ args._.push(raw);
18
+ continue;
19
+ }
20
+ const eq = raw.indexOf("=");
21
+ if (eq === -1) args[raw.slice(2)] = true;
22
+ else args[raw.slice(2, eq)] = raw.slice(eq + 1);
23
+ }
24
+ return args;
25
+ }
26
+
27
+ async function latestVersion(registry = "https://registry.npmjs.org") {
28
+ const base = registry.replace(/\/+$/, "");
29
+ const response = await fetch(`${base}/oh-langfuse`, { headers: { accept: "application/json" } });
30
+ if (!response.ok) throw new Error(`npm registry ${response.status} ${response.statusText}`);
31
+ return extractVersionFromNpmMetadata(await response.json());
32
+ }
33
+
34
+ function question(text) {
35
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
36
+ return new Promise((resolve) => {
37
+ rl.question(text, (answer) => {
38
+ rl.close();
39
+ resolve(String(answer || "").trim());
40
+ });
41
+ });
42
+ }
43
+
44
+ function npxCommand() {
45
+ return process.platform === "win32" ? "npx.cmd" : "npx";
46
+ }
47
+
48
+ function runUpdate(target, args) {
49
+ const updateArgs = ["-y", "oh-langfuse@latest", "update", target];
50
+ if (args["skip-check"]) updateArgs.push("--skip-check");
51
+ if (args.npmRegistry) updateArgs.push(`--npmRegistry=${args.npmRegistry}`);
52
+ if (args.pipIndexUrl) updateArgs.push(`--pipIndexUrl=${args.pipIndexUrl}`);
53
+ const command = npxCommand();
54
+ const result = spawnSync(command, updateArgs, {
55
+ stdio: "inherit",
56
+ shell: process.platform === "win32",
57
+ windowsHide: true,
58
+ timeout: 900000,
59
+ });
60
+ return result.status ?? (result.error ? 1 : 0);
61
+ }
62
+
63
+ async function main() {
64
+ const args = parseArgs(process.argv.slice(2));
65
+ if (/^(0|false|no|off)$/i.test(String(process.env.OH_LANGFUSE_AUTO_UPDATE || ""))) return 0;
66
+
67
+ const target = String(args._[0] || "").trim().toLowerCase();
68
+ if (!ALLOWED_TARGETS.has(target)) {
69
+ throw new Error("Usage: oh-langfuse auto-update <claude|opencode|codex>");
70
+ }
71
+
72
+ let latest;
73
+ try {
74
+ latest = await latestVersion(args.npmRegistry);
75
+ } catch (error) {
76
+ if (args.verbose) console.log(`[INFO] oh-langfuse update check skipped: ${error.message}`);
77
+ return 0;
78
+ }
79
+
80
+ const record = getRuntimeInstallRecord(target);
81
+ const installedVersion = record?.packageVersion || record?.version || "";
82
+ const needsUpdate = installedVersion ? isNewerVersion(latest, installedVersion) : isNewerVersion(latest, packageJson.version);
83
+ if (!needsUpdate && installedVersion) return 0;
84
+ if (!needsUpdate && !installedVersion) return 0;
85
+
86
+ const message = installedVersion
87
+ ? `oh-langfuse ${target} runtime update available: ${installedVersion} -> ${latest}.`
88
+ : `oh-langfuse ${target} runtime may need update. Latest package: ${latest}.`;
89
+
90
+ if (args.yes || args.y) {
91
+ console.log(`[INFO] ${message}`);
92
+ const code = runUpdate(target, args);
93
+ return args.strict ? code : 0;
94
+ }
95
+
96
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
97
+ console.log(`[INFO] ${message} Run: npx oh-langfuse@latest update ${target}`);
98
+ return 0;
99
+ }
100
+
101
+ const answer = await question(`${message}\nUpdate now? (y/N) `);
102
+ if (!/^(y|yes)$/i.test(answer)) return 0;
103
+ const code = runUpdate(target, args);
104
+ return args.strict ? code : 0;
105
+ }
106
+
107
+ main()
108
+ .then((code) => process.exit(code))
109
+ .catch((error) => {
110
+ console.error(`[WARN] oh-langfuse auto-update skipped: ${error?.message || String(error)}`);
111
+ process.exit(0);
112
+ });
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
- import os from "node:os";
4
+ import os from "node:os";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
7
8
 
8
9
  const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
9
10
  const USER_ID_PATTERN_TEXT = "^[a-z](?:\\d{8}|wx\\d{7})$";
@@ -57,11 +58,46 @@ function tomlArray(items) {
57
58
  return `[${items.map(tomlString).join(", ")}]`;
58
59
  }
59
60
 
60
- function pythonExecutableInVenv(venvDir) {
61
- return process.platform === "win32"
62
- ? path.join(venvDir, "Scripts", "python.exe")
63
- : path.join(venvDir, "bin", "python");
64
- }
61
+ function pythonExecutableInVenv(venvDir) {
62
+ return process.platform === "win32"
63
+ ? path.join(venvDir, "Scripts", "python.exe")
64
+ : path.join(venvDir, "bin", "python");
65
+ }
66
+
67
+ function shQuote(s) {
68
+ return `'${String(s).replace(/'/g, "'\"'\"'")}'`;
69
+ }
70
+
71
+ function createAgentLauncher({ baseDir, target, executable }) {
72
+ if (process.platform === "win32") {
73
+ const launcher = path.join(baseDir, `launch-${target}-langfuse.cmd`);
74
+ const content = [
75
+ "@echo off",
76
+ "where npx >nul 2>nul",
77
+ "if %ERRORLEVEL% EQU 0 (",
78
+ ` call npx -y oh-langfuse@latest auto-update ${target} --skip-check`,
79
+ ")",
80
+ `${executable} %*`,
81
+ ""
82
+ ].join(os.EOL);
83
+ fs.writeFileSync(launcher, content, "utf8");
84
+ return launcher;
85
+ }
86
+
87
+ const launcher = path.join(baseDir, `launch-${target}-langfuse.sh`);
88
+ const content = [
89
+ "#!/usr/bin/env sh",
90
+ "set -eu",
91
+ "if command -v npx >/dev/null 2>&1; then",
92
+ ` npx -y oh-langfuse@latest auto-update ${target} --skip-check || true`,
93
+ "fi",
94
+ `exec ${shQuote(executable)} "$@"`,
95
+ ""
96
+ ].join("\n");
97
+ fs.writeFileSync(launcher, content, "utf8");
98
+ fs.chmodSync(launcher, 0o755);
99
+ return launcher;
100
+ }
65
101
 
66
102
  function createOrUpdateLangfuseVenv({ baseDir, pipIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple" }) {
67
103
  const venvDir = path.join(baseDir, "langfuse-venv");
@@ -134,9 +170,10 @@ function updateCodexNotify(configPath, notifyCommand) {
134
170
  fs.writeFileSync(configPath, next.join(os.EOL), "utf8");
135
171
  }
136
172
 
137
- async function main() {
138
- const args = parseArgs(process.argv.slice(2));
139
- const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
173
+ async function main() {
174
+ const args = parseArgs(process.argv.slice(2));
175
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
176
+ const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
140
177
 
141
178
  const publicKey =
142
179
  args.publicKey || process.env.LANGFUSE_PUBLIC_KEY || "pk-lf-da0c90a7-6e93-4eb7-bb86-c1047c8d187d";
@@ -188,14 +225,21 @@ async function main() {
188
225
  const notifyPython = args["skip-pip-install"]
189
226
  ? pythonCmd
190
227
  : createOrUpdateLangfuseVenv({ baseDir: codexHome, pipIndexUrl });
191
- updateCodexNotify(configPath, [notifyPython, destHook]);
192
-
193
- console.log(`Updated Codex notify hook: ${configPath}`);
194
- console.log(`Installed hook script: ${destHook}`);
195
- console.log(`Wrote Langfuse config: ${path.join(langfuseDir, "config.json")}`);
196
-
197
- console.log("Done. Restart Codex so the updated notify command is loaded.");
198
- }
228
+ updateCodexNotify(configPath, [notifyPython, destHook]);
229
+ const agentLauncher = createAgentLauncher({ baseDir: codexHome, target: "codex", executable: "codex" });
230
+
231
+ console.log(`Updated Codex notify hook: ${configPath}`);
232
+ console.log(`Installed hook script: ${destHook}`);
233
+ console.log(`Wrote Langfuse config: ${path.join(langfuseDir, "config.json")}`);
234
+ console.log(`Agent launcher with update check: ${agentLauncher}`);
235
+ const runtimeState = writeRuntimeInstallRecord("codex", {
236
+ packageName: packageJson.name,
237
+ packageVersion: packageJson.version,
238
+ });
239
+ console.log(`Runtime version recorded: ${runtimeState}`);
240
+
241
+ console.log("Done. Restart Codex so the updated notify command is loaded.");
242
+ }
199
243
 
200
244
  main().catch((err) => {
201
245
  console.error(err?.message || String(err));
@@ -4,8 +4,10 @@ import path from "node:path";
4
4
  import os from "node:os";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
7
8
 
8
9
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
9
11
 
10
12
  const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
11
13
  const USER_ID_PATTERN_TEXT = "^[a-z](?:\\d{8}|wx\\d{7})$";
@@ -147,6 +149,37 @@ function shQuote(s) {
147
149
  return `'${String(s).replace(/'/g, "'\\''")}'`;
148
150
  }
149
151
 
152
+ function createAgentLauncher({ baseDir, target, executable }) {
153
+ if (process.platform === "win32") {
154
+ const launcher = path.join(baseDir, `launch-${target}-langfuse.cmd`);
155
+ const content = [
156
+ "@echo off",
157
+ "where npx >nul 2>nul",
158
+ "if %ERRORLEVEL% EQU 0 (",
159
+ ` call npx -y oh-langfuse@latest auto-update ${target} --skip-check`,
160
+ ")",
161
+ `${executable} %*`,
162
+ ""
163
+ ].join(os.EOL);
164
+ fs.writeFileSync(launcher, content, "utf8");
165
+ return launcher;
166
+ }
167
+
168
+ const launcher = path.join(baseDir, `launch-${target}-langfuse.sh`);
169
+ const content = [
170
+ "#!/usr/bin/env sh",
171
+ "set -eu",
172
+ "if command -v npx >/dev/null 2>&1; then",
173
+ ` npx -y oh-langfuse@latest auto-update ${target} --skip-check || true`,
174
+ "fi",
175
+ `exec ${shQuote(executable)} "$@"`,
176
+ ""
177
+ ].join("\n");
178
+ fs.writeFileSync(launcher, content, "utf8");
179
+ fs.chmodSync(launcher, 0o755);
180
+ return launcher;
181
+ }
182
+
150
183
  function pythonExecutableInVenv(venvDir) {
151
184
  return process.platform === "win32"
152
185
  ? path.join(venvDir, "Scripts", "python.exe")
@@ -288,6 +321,7 @@ async function main() {
288
321
  }
289
322
  const hookPython = createOrUpdateLangfuseVenv({ baseDir: claudeDir, pipIndexUrl });
290
323
  const hookLauncher = await createHookLauncher({ hooksDir, hookPython, pyPath });
324
+ const agentLauncher = createAgentLauncher({ baseDir: claudeDir, target: "claude", executable: "claude" });
291
325
 
292
326
  // 4) 合并写入 settings.json
293
327
  const settingsPath = path.join(claudeDir, "settings.json");
@@ -322,10 +356,16 @@ async function main() {
322
356
 
323
357
  const merged = deepMerge(existing, desired);
324
358
  ensureDir(claudeDir);
325
- writeJsonPretty(settingsPath, merged);
326
- console.log(`已更新:${settingsPath}`);
327
-
328
- }
359
+ writeJsonPretty(settingsPath, merged);
360
+ const runtimeState = writeRuntimeInstallRecord("claude", {
361
+ packageName: packageJson.name,
362
+ packageVersion: packageJson.version,
363
+ });
364
+ console.log(`Runtime version recorded: ${runtimeState}`);
365
+ console.log(`已更新:${settingsPath}`);
366
+ console.log(`Agent launcher with update check: ${agentLauncher}`);
367
+
368
+ }
329
369
 
330
370
  main().catch((err) => {
331
371
  console.error(err?.message || String(err));
@@ -1,12 +1,16 @@
1
- import fs from "node:fs";
1
+ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { parseJsonRelaxed, stripBom } from "./json-utils.mjs";
6
7
  import { shouldSuppressAgentLogLine } from "./log-filter-utils.mjs";
8
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
7
9
 
8
10
  const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
9
11
  const USER_ID_PATTERN_TEXT = "^[a-z](?:\\d{8}|wx\\d{7})$";
12
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
10
14
 
11
15
  function normalizeUserId(v) {
12
16
  return String(v || "").trim();
@@ -560,11 +564,15 @@ function writeWindowsLauncherCmd(opencodeDir, { publicKey, secretKey, baseUrl, u
560
564
  if (process.platform !== "win32") return null;
561
565
  const p = path.join(opencodeDir, "launch-opencode-langfuse.cmd");
562
566
  const cmd = ["@echo off", "REM Auto-generated by scripts/opencode-langfuse-setup.mjs"];
563
- cmd.push(`set LANGFUSE_PUBLIC_KEY=${publicKey}`);
564
- cmd.push(`set LANGFUSE_SECRET_KEY=${secretKey}`);
565
- cmd.push(`set LANGFUSE_BASEURL=${baseUrl}`);
566
- if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
567
- cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.exe" (');
567
+ cmd.push(`set LANGFUSE_PUBLIC_KEY=${publicKey}`);
568
+ cmd.push(`set LANGFUSE_SECRET_KEY=${secretKey}`);
569
+ cmd.push(`set LANGFUSE_BASEURL=${baseUrl}`);
570
+ if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
571
+ cmd.push("where npx >nul 2>nul");
572
+ cmd.push("if %ERRORLEVEL% EQU 0 (");
573
+ cmd.push(" call npx -y oh-langfuse@latest auto-update opencode --skip-check");
574
+ cmd.push(")");
575
+ cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.exe" (');
568
576
  cmd.push(' "%USERPROFILE%\\.opencode\\bin\\opencode.exe" %*');
569
577
  cmd.push(" exit /b %ERRORLEVEL%");
570
578
  cmd.push(")");
@@ -588,6 +596,9 @@ function writeUnixLauncherSh(opencodeDir, { publicKey, secretKey, baseUrl, userI
588
596
  `export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
589
597
  `export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
590
598
  userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
599
+ 'if command -v npx >/dev/null 2>&1; then',
600
+ ' npx -y oh-langfuse@latest auto-update opencode --skip-check || true',
601
+ "fi",
591
602
  'if [ -x "$HOME/.opencode/bin/opencode" ]; then',
592
603
  ' exec "$HOME/.opencode/bin/opencode" "$@"',
593
604
  "fi",
@@ -906,6 +917,11 @@ async function main() {
906
917
  }
907
918
 
908
919
  console.log(paint("完成。", colors.green, colors.bold));
920
+ const runtimeState = writeRuntimeInstallRecord("opencode", {
921
+ packageName: packageJson.name,
922
+ packageVersion: packageJson.version,
923
+ });
924
+ console.log(`Runtime version recorded: ${runtimeState}`);
909
925
  printCommandHint("可运行以下命令校验:", "npx oh-langfuse@latest check opencode");
910
926
  if (process.platform !== "win32") {
911
927
  console.log("");
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const STATE_VERSION = 1;
6
+
7
+ export function runtimeStateDir(home = os.homedir()) {
8
+ return path.join(home, ".config", "oh-langfuse");
9
+ }
10
+
11
+ export function runtimeStatePath(home = os.homedir()) {
12
+ return path.join(runtimeStateDir(home), "runtime.json");
13
+ }
14
+
15
+ function stripBom(s) {
16
+ return typeof s === "string" && s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
17
+ }
18
+
19
+ export function readRuntimeState(home = os.homedir()) {
20
+ const p = runtimeStatePath(home);
21
+ try {
22
+ if (!fs.existsSync(p)) return { version: STATE_VERSION, targets: {} };
23
+ const text = stripBom(fs.readFileSync(p, "utf8"));
24
+ if (!text.trim()) return { version: STATE_VERSION, targets: {} };
25
+ const parsed = JSON.parse(text);
26
+ return {
27
+ version: parsed.version || STATE_VERSION,
28
+ targets: parsed.targets && typeof parsed.targets === "object" ? parsed.targets : {},
29
+ };
30
+ } catch {
31
+ return { version: STATE_VERSION, targets: {} };
32
+ }
33
+ }
34
+
35
+ export function getRuntimeInstallRecord(target, home = os.homedir()) {
36
+ return readRuntimeState(home).targets?.[target] || null;
37
+ }
38
+
39
+ export function writeRuntimeInstallRecord(target, record = {}, home = os.homedir()) {
40
+ const state = readRuntimeState(home);
41
+ const dir = runtimeStateDir(home);
42
+ fs.mkdirSync(dir, { recursive: true });
43
+ state.version = STATE_VERSION;
44
+ state.targets = state.targets || {};
45
+ state.targets[target] = {
46
+ ...state.targets[target],
47
+ ...record,
48
+ target,
49
+ updatedAt: new Date().toISOString(),
50
+ };
51
+ fs.writeFileSync(runtimeStatePath(home), JSON.stringify(state, null, 2) + os.EOL, "utf8");
52
+ return runtimeStatePath(home);
53
+ }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { extractVersionFromNpmMetadata, selectUpdateTargets } from "./update-utils.mjs";
7
+ import { writeRuntimeInstallRecord } from "./runtime-state-utils.mjs";
7
8
 
8
9
  const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
10
  const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
@@ -166,6 +167,10 @@ async function main() {
166
167
  console.log(`[INFO] Updating ${target} runtime...`);
167
168
  const config = mergedConfig(target, args);
168
169
  runNodeScript(setupScript(target), setupArgs(target, config, args));
170
+ writeRuntimeInstallRecord(target, {
171
+ packageName: packageJson.name,
172
+ packageVersion: packageJson.version,
173
+ });
169
174
  if (!args["skip-check"]) {
170
175
  runNodeScript(checkScript(target), []);
171
176
  }
@@ -8,6 +8,33 @@ export function extractVersionFromNpmMetadata(metadata) {
8
8
  return latest.trim();
9
9
  }
10
10
 
11
+ function versionParts(version) {
12
+ return String(version || "")
13
+ .trim()
14
+ .replace(/^v/i, "")
15
+ .split("-")[0]
16
+ .split(".")
17
+ .map((part) => Number.parseInt(part, 10) || 0);
18
+ }
19
+
20
+ export function compareSemver(a, b) {
21
+ const left = versionParts(a);
22
+ const right = versionParts(b);
23
+ const length = Math.max(left.length, right.length, 3);
24
+ for (let i = 0; i < length; i += 1) {
25
+ const l = left[i] || 0;
26
+ const r = right[i] || 0;
27
+ if (l > r) return 1;
28
+ if (l < r) return -1;
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ export function isNewerVersion(candidate, current) {
34
+ if (!candidate || !current) return false;
35
+ return compareSemver(candidate, current) > 0;
36
+ }
37
+
11
38
  export function selectUpdateTargets(target = "all", installed = {}) {
12
39
  const normalized = String(target || "all").trim().toLowerCase();
13
40
  if (normalized === "all") {