@kitsunemori96/open-session-metrics 0.3.0 → 0.3.1

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
@@ -24,13 +24,9 @@ osm # 起服务并打开浏览器
24
24
  osm status # 谁在跑、数据在哪
25
25
  osm logs # 跟踪服务日志
26
26
  osm stop # 停掉后台服务
27
+ osm upgrade # 装最新版(--check 只看不装)
27
28
  ```
28
29
 
29
- > 0.3.0 起项目从 `opencode-session-metrics` 改名为 **Open Session Metrics**(包名
30
- > `@kitsunemori96/open-session-metrics`,CLI 仍是 `osm`)。旧包会标记 deprecated;
31
- > 已全局安装旧包的用户重装一次即可。旧的数据目录
32
- > `~/.local/share/opencode-session-metrics` 会被继续沿用,不迁移、不丢历史。
33
-
34
30
  要求 **Node 24+**(使用内置 `node:sqlite`,无原生依赖),且本机至少有一种数据源:
35
31
  `~/.local/share/opencode/opencode.db`(OpenCode)或 `~/.qoder/projects`(Qoder CLI)。
36
32
  默认源自动探测,也可以 `OSM_SOURCE=qoder osm` 指定,或启动后在页眉切换
@@ -38,6 +34,39 @@ osm stop # 停掉后台服务
38
34
  `~/.local/share/open-session-metrics`(`OSM_DATA_DIR` 或 `osm --data` 可改),
39
35
  升级包不会动到它。`osm run` 前台运行,`--port` / `--host` 改绑定。
40
36
 
37
+ ### 更新
38
+
39
+ `osm` 每 24 小时查一次 npm registry,结果缓存在数据目录的 `update-check.json`。
40
+ 有新版本时,`osm start` / `osm run` / `osm status` 末尾追加一行(走 stderr,
41
+ `status` 的 stdout 仍可直接解析):
42
+
43
+ ```
44
+ osm: 0.3.1 available (running 0.3.0) — osm upgrade
45
+ ```
46
+
47
+ `osm upgrade` 用当前的安装方式(npm / pnpm / yarn / bun)装 `@latest`,服务在跑就
48
+ 顺手重启到新版本;`osm upgrade --check` 只报告、不安装。检查失败只留缓存、一天内
49
+ 不再重试,`CI` 环境整个跳过,`OSM_OFFLINE=1` 不联网(仍可读已有缓存),
50
+ `OSM_NO_UPDATE_CHECK=1` 彻底关掉。通过旧包桥接安装的副本不会原地升级——那会把旧
51
+ 名字一直留在最外层——而是打印「卸旧装新」两条命令(见[从旧包迁移](#从旧包迁移))。
52
+ 源码 checkout 里 `osm upgrade` 会拒绝执行并提示 `git pull`,避免包管理器覆盖工作区。
53
+
54
+ ### 从旧包迁移
55
+
56
+ 0.3.0 起项目从 `opencode-session-metrics` 改名为 **Open Session Metrics**。旧包收到了
57
+ 一个 0.3.0 桥接版:它依赖新包并转调 `osm`,所以 `npm update -g` 之后旧名字仍然可用,
58
+ 跑的是新代码。
59
+
60
+ ```bash
61
+ npm update -g @kitsunemori96/opencode-session-metrics # 拉桥接版:命令、数据都不变
62
+ npm uninstall -g @kitsunemori96/opencode-session-metrics
63
+ npm i -g @kitsunemori96/open-session-metrics # 想彻底换成新名字时
64
+ ```
65
+
66
+ 数据目录沿用旧的 `~/.local/share/opencode-session-metrics`(新目录不存在时),
67
+ `OSM_*` 环境变量与 `osm` 命令不变,指标库不需要重建。两个包都提供 `osm`,
68
+ 所以切换时要先卸旧再装新,否则卸载旧包可能带走命令链接。
69
+
41
70
  从源码运行见[快速开始](#快速开始)。
42
71
 
43
72
  ## 界面
@@ -355,7 +384,8 @@ pid 与日志落在数据目录的 `osm.{pid,log}`。
355
384
  pnpm dev # server + web
356
385
  pnpm serve # 构建 + 重启 8787(见「快速开始」)
357
386
  pnpm typecheck # 两个包 tsc --noEmit
358
- pnpm test # ETL / Qoder / 数据源 / 不变量测试(离线 fixture,无需服务与网络)
387
+ pnpm test # ETL / Qoder / 数据源 / 不变量测试 + CLI 更新检查(离线 fixture,无需服务与网络)
388
+ pnpm test:cli # 只跑 CLI 更新检查的单元测试(node:test)
359
389
  pnpm -F @osm/server test:smoke # HTTP 冒烟测试(需服务在线)
360
390
  pnpm materialize # 增量物化
361
391
  pnpm materialize --full # 全量重建(写入临时文件后原子替换)
@@ -374,10 +404,12 @@ pnpm plans # 按两库实测单价算套餐性价比(见 docs
374
404
  | `OSM_SOURCE` | 自动 | 默认数据源:`opencode` 或 `qoder`(页眉可切换,不设则自动探测) |
375
405
  | `OSM_SOURCE_DB` | `~/.local/share/opencode/opencode.db` | OpenCode 源库路径(只读) |
376
406
  | `OSM_QODER_HOME` | `~/.qoder` | Qoder 数据根目录(读其下 `projects/` 与 `logs/`) |
377
- | `OSM_DATA_DIR` | `<项目根>/.data` | 两库(`metrics.db` / `metrics-qoder.db`)与价目缓存目录(已 gitignore) |
407
+ | `OSM_DATA_DIR` | `<项目根>/.data` | 两库(`metrics.db` / `metrics-qoder.db`)、价目与更新检查缓存(已 gitignore) |
378
408
  | `OSM_HOST` | `127.0.0.1` | API 监听地址 |
379
409
  | `OSM_PORT` | `8787` | API 端口 |
380
- | `OSM_OFFLINE` | — | 设为 `1` 时不访问 models.dev,价目走缓存或零值兜底 |
410
+ | `OSM_OFFLINE` | — | 设为 `1` 时不访问 models.dev 与 npm 更新检查(版本检查仍可读缓存) |
411
+ | `OSM_NO_UPDATE_CHECK` | — | 设为 `1` 关闭每日版本检查,不写缓存也不发请求 |
412
+ | `OSM_UPDATE_REGISTRY` | `https://registry.npmjs.org` | 版本检查用的 registry(未设时沿用 `npm_config_registry`) |
381
413
 
382
414
  ## 实测性能
383
415
 
@@ -407,7 +439,8 @@ Qoder 的语料是另一套量级:67MB 逐行转录 · 24 个会话(含 8
407
439
  ## 目录结构
408
440
 
409
441
  ```
410
- bin/osm.js # npm 入口:起服务、开浏览器、stop/status/logs
442
+ bin/osm.js # npm 入口:起服务、开浏览器、stop/status/logs/upgrade
443
+ bin/update-check.js # 每日一次的 registry 版本检查(纯函数,离线可测)
411
444
  server/src/
412
445
  ├── index.ts # 启动、静态托管、水位驱动的刷新循环
413
446
  ├── api.ts # REST 端点(每库一份,x-osm-source 选库)
package/bin/osm.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // One process, one port: Express serves both the API and the built web bundle.
12
12
  // State goes to the XDG data dir (`~/.local/share/open-session-metrics`), never
13
13
  // into the package directory, so `npm i -g` upgrades cannot delete it.
14
- import { execFileSync, spawn } from "node:child_process";
14
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
15
15
  import {
16
16
  createReadStream,
17
17
  existsSync,
@@ -26,6 +26,7 @@ import {
26
26
  import { homedir } from "node:os";
27
27
  import path from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
+ import { checkForUpdate, isNewer } from "./update-check.js";
29
30
 
30
31
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
31
32
  const PKG = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8"));
@@ -48,6 +49,7 @@ Usage
48
49
  osm status process, URL, data dir, source db, bundle age
49
50
  osm logs [-n N] follow the server log (default: last 40 lines)
50
51
  osm open open the browser only
52
+ osm upgrade [--check] install the latest release globally (--check only reports)
51
53
 
52
54
  Options
53
55
  --port <n> listen port (default: $OSM_PORT or 8787)
@@ -58,10 +60,14 @@ Options
58
60
  -f, --foreground same as \`osm run\`
59
61
  -v, --version print the version
60
62
  -h, --help print this help
63
+
64
+ Updates
65
+ osm checks the npm registry once a day and prints one line when a newer
66
+ release exists. OSM_NO_UPDATE_CHECK=1 disables the check.
61
67
  `;
62
68
 
63
69
  const argv = process.argv.slice(2);
64
- const flags = { open: true, foreground: false };
70
+ const flags = { open: true, foreground: false, check: false };
65
71
  const positional = [];
66
72
  let logLines = 40;
67
73
 
@@ -101,6 +107,9 @@ for (let i = 0; i < argv.length; i += 1) {
101
107
  case "--foreground":
102
108
  flags.foreground = true;
103
109
  break;
110
+ case "--check":
111
+ flags.check = true;
112
+ break;
104
113
  case "-n":
105
114
  logLines = Number(argv[(i += 1)]);
106
115
  break;
@@ -112,6 +121,7 @@ for (let i = 0; i < argv.length; i += 1) {
112
121
 
113
122
  const command = positional[0] ?? "start";
114
123
  if (positional.length > 1) fail(`unexpected argument: ${positional[1]}`);
124
+ if (flags.check && command !== "upgrade") fail("--check is only valid with `osm upgrade`");
115
125
 
116
126
  const HOST = flags.host ?? process.env.OSM_HOST ?? "127.0.0.1";
117
127
  const PORT = flags.port ?? Number(process.env.OSM_PORT ?? 8787);
@@ -129,6 +139,7 @@ const USER_DATA_DIR = (() => {
129
139
  return existsSync(legacy) && !existsSync(current) ? legacy : current;
130
140
  })();
131
141
  const DEFAULT_DATA_DIR = ROOT.includes(INSTALLED) ? USER_DATA_DIR : path.join(ROOT, ".data");
142
+ const IS_INSTALLED = ROOT.includes(INSTALLED);
132
143
  const DATA_DIR = path.resolve(flags.data ?? process.env.OSM_DATA_DIR ?? DEFAULT_DATA_DIR);
133
144
  const SOURCE_DB =
134
145
  flags.source ??
@@ -301,6 +312,16 @@ function checkInstall() {
301
312
  }
302
313
 
303
314
  async function start(foreground) {
315
+ const notice = updateNotice().catch(() => undefined);
316
+ try {
317
+ await startServer(foreground);
318
+ } finally {
319
+ // The check decorates the command; it must never change its outcome.
320
+ await notice;
321
+ }
322
+ }
323
+
324
+ async function startServer(foreground) {
304
325
  checkInstall();
305
326
 
306
327
  if (await isUp()) {
@@ -411,6 +432,7 @@ async function stop() {
411
432
  }
412
433
 
413
434
  async function status() {
435
+ const notice = updateNotice().catch(() => undefined);
414
436
  const pid = readPid();
415
437
  const up = await isUp();
416
438
  // One lookup either way: a pid file is authoritative, the listener is the fallback.
@@ -435,6 +457,119 @@ async function status() {
435
457
  } else {
436
458
  note(" bundle missing");
437
459
  }
460
+ await notice;
461
+ }
462
+
463
+ /** Set once this process has installed a newer release: PKG.version is stale. */
464
+ let upgraded = false;
465
+
466
+ /**
467
+ * One line on stderr at most once a day. stdout stays parseable for `status`,
468
+ * and a slow or missing registry is silent rather than a delayed command.
469
+ */
470
+ async function updateNotice() {
471
+ if (!IS_INSTALLED || upgraded) return;
472
+ const info = await checkForUpdate({ name: PKG.name, current: PKG.version, dataDir: DATA_DIR });
473
+ if (!info?.newer) return;
474
+ process.stderr.write(`osm: ${info.latest} available (running ${PKG.version}) — osm upgrade\n`);
475
+ }
476
+
477
+ const BRIDGE_PACKAGE = "@kitsunemori96/opencode-session-metrics";
478
+ const MANAGERS = {
479
+ npm: { install: (spec) => ["install", "-g", spec], uninstall: (name) => ["uninstall", "-g", name] },
480
+ pnpm: { install: (spec) => ["add", "-g", spec], uninstall: (name) => ["remove", "-g", name] },
481
+ yarn: { install: (spec) => ["global", "add", spec], uninstall: (name) => ["global", "remove", name] },
482
+ bun: { install: (spec) => ["add", "-g", spec], uninstall: (name) => ["remove", "-g", name] },
483
+ };
484
+
485
+ /** Which tool owns the global prefix, inferred from where this file lives. */
486
+ function globalManager() {
487
+ const root = ROOT.toLowerCase();
488
+ const sep = path.sep;
489
+ if (process.env.PNPM_HOME && root.startsWith(path.join(process.env.PNPM_HOME, "global").toLowerCase())) return "pnpm";
490
+ if (root.includes(`${sep}pnpm${sep}global${sep}`)) return "pnpm";
491
+ if (root.includes(`${sep}.bun${sep}install${sep}global${sep}`)) return "bun";
492
+ if (root.includes(`${sep}yarn${sep}global${sep}`) || root.includes(`${sep}.config${sep}yarn${sep}`)) return "yarn";
493
+ return "npm";
494
+ }
495
+
496
+ /**
497
+ * The bridge package installs the renamed one as a nested dependency:
498
+ * <prefix>/node_modules/@kitsunemori96/opencode-session-metrics/node_modules/@kitsunemori96/open-session-metrics
499
+ * Upgrading that in place would keep the old top-level name forever, so the
500
+ * migration is left to the user, printed as the two commands that end it.
501
+ */
502
+ const installedThroughBridge = () =>
503
+ ROOT.toLowerCase().includes(`${path.sep}opencode-session-metrics${path.sep}node_modules${path.sep}`);
504
+
505
+ const commandLine = (manager, args) => [manager, ...args].join(" ");
506
+
507
+ /** Windows installs npm/pnpm/yarn as `.cmd` shims; `onPath("npm")` would miss them. */
508
+ function managerBin(manager) {
509
+ if (process.platform !== "win32") return onPath(manager) ? manager : null;
510
+ return [`${manager}.cmd`, `${manager}.exe`, manager].find(onPath) ?? null;
511
+ }
512
+
513
+ async function upgrade() {
514
+ if (!flags.check && !IS_INSTALLED) {
515
+ fail("this is a source checkout — update with: git pull && pnpm install && pnpm serve");
516
+ }
517
+
518
+ const spec = `${PKG.name}@latest`;
519
+ const manager = globalManager();
520
+ const { install, uninstall } = MANAGERS[manager];
521
+
522
+ // Answer the migration question before the registry: it needs no network,
523
+ // and `@latest` is the spec either way.
524
+ if (!flags.check && installedThroughBridge()) {
525
+ note("this copy was installed by the deprecated @kitsunemori96/opencode-session-metrics bridge");
526
+ note(` ${commandLine(manager, uninstall(BRIDGE_PACKAGE))}`);
527
+ note(` ${commandLine(manager, install(spec))}`);
528
+ note("the osm command, OSM_* variables and the data dir all survive the switch");
529
+ process.exit(1);
530
+ }
531
+
532
+ const info = await checkForUpdate({
533
+ name: PKG.name,
534
+ current: PKG.version,
535
+ dataDir: DATA_DIR,
536
+ force: true,
537
+ // The user is waiting on this one, but a slow registry is still bounded.
538
+ timeout: 4000,
539
+ });
540
+
541
+ if (flags.check) {
542
+ if (info?.error) fail(`could not check the registry (${info.error})`);
543
+ if (info.newer) note(`${info.latest} available (running ${PKG.version}) — osm upgrade`);
544
+ else if (isNewer(PKG.version, info.latest)) note(`running ${PKG.version}; the registry has ${info.latest}`);
545
+ else note(`${PKG.version} is the latest`);
546
+ return;
547
+ }
548
+
549
+ if (info?.error) note(`could not check the registry (${info.error}) — installing ${spec} anyway`);
550
+ else if (!info.newer) {
551
+ note(`${PKG.version} is already the latest`);
552
+ return;
553
+ } else note(`installing ${info.latest} (running ${PKG.version})…`);
554
+
555
+ const args = install(spec);
556
+ const bin = managerBin(manager);
557
+ if (!bin) fail(`${manager} is not on PATH — run: ${commandLine(manager, args)}`);
558
+
559
+ const wasUp = await isUp();
560
+ const child = spawnSync(bin, args, { stdio: "inherit" });
561
+ if (child.error) fail(`${manager} failed: ${child.error.message}`);
562
+ if (child.status !== 0) fail(`${manager} exited with status ${child.status}`);
563
+
564
+ // This process still has the old version in memory; restart would otherwise
565
+ // announce the release it just installed as an available update.
566
+ upgraded = true;
567
+ if (wasUp) {
568
+ note("installed — restarting the server on the new version");
569
+ await restart();
570
+ } else {
571
+ note("installed — start it with: osm");
572
+ }
438
573
  }
439
574
 
440
575
  function follow() {
@@ -473,6 +608,9 @@ switch (command) {
473
608
  case "status":
474
609
  await status();
475
610
  break;
611
+ case "upgrade":
612
+ await upgrade();
613
+ break;
476
614
  case "logs":
477
615
  case "log":
478
616
  follow();
@@ -0,0 +1,132 @@
1
+ // Once-a-day registry lookups, kept out of bin/osm.js so the logic can be
2
+ // tested without spawning a server and without touching the network.
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+
6
+ export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
7
+ export const DEFAULT_REGISTRY = "https://registry.npmjs.org";
8
+ const FETCH_TIMEOUT_MS = 1200;
9
+ const CACHE_FILE = "update-check.json";
10
+
11
+ /** `0.3.1`, `v0.3.1`, `0.3.1-rc.1` → parts; anything else → null. */
12
+ export function parseVersion(input) {
13
+ if (typeof input !== "string") return null;
14
+ const match = input.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
15
+ if (!match) return null;
16
+ return {
17
+ major: Number(match[1]),
18
+ minor: Number(match[2]),
19
+ patch: Number(match[3]),
20
+ pre: match[4] ?? null,
21
+ };
22
+ }
23
+
24
+ export function compareVersions(a, b) {
25
+ const left = parseVersion(a);
26
+ const right = parseVersion(b);
27
+ if (!left || !right) return 0;
28
+ for (const key of ["major", "minor", "patch"]) {
29
+ if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1;
30
+ }
31
+ // A release outranks any prerelease of the same core: 0.3.1 > 0.3.1-rc.1.
32
+ if (left.pre === right.pre) return 0;
33
+ if (left.pre === null) return 1;
34
+ if (right.pre === null) return -1;
35
+ return left.pre < right.pre ? -1 : 1;
36
+ }
37
+
38
+ export const isNewer = (candidate, current) => compareVersions(candidate, current) > 0;
39
+
40
+ /** Scoped names must be percent-encoded on the `/latest` route. */
41
+ export function registryUrl(name, registry = DEFAULT_REGISTRY) {
42
+ const base = (registry || DEFAULT_REGISTRY).replace(/\/+$/, "");
43
+ return `${base}/${name.replace("/", "%2F")}/latest`;
44
+ }
45
+
46
+ export const cachePath = (dataDir) => path.join(dataDir, CACHE_FILE);
47
+
48
+ export function readCache(file, name, now = Date.now()) {
49
+ try {
50
+ const raw = JSON.parse(readFileSync(file, "utf8"));
51
+ if (!raw || raw.package !== name || !Number.isFinite(raw.checkedAt)) return null;
52
+ if (raw.latest !== null && typeof raw.latest !== "string") return null;
53
+ return { latest: raw.latest, checkedAt: raw.checkedAt, fresh: now - raw.checkedAt < CHECK_INTERVAL_MS };
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function writeCache(file, name, latest, now = Date.now()) {
60
+ try {
61
+ mkdirSync(path.dirname(file), { recursive: true });
62
+ writeFileSync(file, `${JSON.stringify({ package: name, checkedAt: now, latest }, null, 2)}\n`);
63
+ } catch {
64
+ // A read-only or full data dir must not fail a command that was only checking.
65
+ }
66
+ }
67
+
68
+ async function fetchLatest({ name, registry, fetchImpl, timeout }) {
69
+ const res = await fetchImpl(registryUrl(name, registry), {
70
+ signal: AbortSignal.timeout(timeout),
71
+ headers: { accept: "application/json" },
72
+ });
73
+ if (!res.ok) throw new Error(`registry → HTTP ${res.status}`);
74
+ const body = await res.json();
75
+ if (!body || typeof body.version !== "string") throw new Error("registry response has no version");
76
+ return body.version;
77
+ }
78
+
79
+ /**
80
+ * The latest published version, from cache when it is under 24h old.
81
+ *
82
+ * `force` is for `osm upgrade`, where the user asked right now: it skips the
83
+ * cache, the daily opt-out and the offline guard. Ambient calls stay silent on
84
+ * failure and fall back to the last stored answer.
85
+ *
86
+ * A failure also writes the cache (`latest: null`, or the previous answer), so
87
+ * an unreachable registry costs one timeout per day rather than one per command.
88
+ */
89
+ export async function checkForUpdate({
90
+ name,
91
+ current,
92
+ dataDir,
93
+ env = process.env,
94
+ fetchImpl = fetch,
95
+ now = Date.now(),
96
+ force = false,
97
+ timeout = FETCH_TIMEOUT_MS,
98
+ }) {
99
+ if (!force && (env.OSM_NO_UPDATE_CHECK || env.CI)) return null;
100
+
101
+ const file = cachePath(dataDir);
102
+ const cached = readCache(file, name, now);
103
+ if (!force && cached?.fresh) {
104
+ return cached.latest === null ? null : result(cached.latest, current, "cache");
105
+ }
106
+
107
+ if (!force && env.OSM_OFFLINE) {
108
+ // Offline still honours a stored answer, but never opens a socket.
109
+ return cached && cached.latest !== null ? result(cached.latest, current, "cache") : null;
110
+ }
111
+
112
+ try {
113
+ const latest = await fetchLatest({
114
+ name,
115
+ registry: env.OSM_UPDATE_REGISTRY || env.npm_config_registry,
116
+ fetchImpl,
117
+ timeout,
118
+ });
119
+ writeCache(file, name, latest, now);
120
+ return result(latest, current, "registry");
121
+ } catch (error) {
122
+ writeCache(file, name, cached?.latest ?? null, now);
123
+ // Keep the error on the cached answer: an ambient notice ignores it, but
124
+ // `osm upgrade --check` must not report stale data as a fresh answer.
125
+ if (cached && cached.latest !== null) return { ...result(cached.latest, current, "cache"), error: error.message };
126
+ return { error: error.message, current };
127
+ }
128
+ }
129
+
130
+ function result(latest, current, from) {
131
+ return { latest, current, from, newer: isNewer(latest, current) };
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitsunemori96/open-session-metrics",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A local-first web cockpit for coding session metrics across OpenCode and Qoder: tokens, cost, tools, and transcripts.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,7 +47,8 @@
47
47
  "plans": "pnpm -F @osm/server plans",
48
48
  "start": "pnpm -F @osm/server start",
49
49
  "typecheck": "pnpm -r typecheck",
50
- "test": "pnpm -r test"
50
+ "test": "pnpm -r test && node --test test/update-check.test.mjs",
51
+ "test:cli": "node --test test/update-check.test.mjs"
51
52
  },
52
53
  "dependencies": {
53
54
  "cors": "^2.8.5",