diff --git a/README.md b/README.md index 7881f85..f8d0bc0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Render order (top → bottom): **info · prayers · money · zai**. | Line | Contents | |---|---| -| **info** | Local weekday + clock — `Wed 21:12` · Hijri date with Gregorian gloss — `27 Rabīʿ al-awwal 1448 (09 Sep 2026)` · city · version stamp — `omp 18.3.4 · sl 0.6.2` (host from omp's launch marker, `omp --version` spawn fallback; plugin from its own package.json) — all dim. The Hijri date advances at Maghrib, not civil midnight (evening value via Aladhan `gToH`, cached per city-day; on failure it stays on today's date). | +| **info** | Local weekday + clock — `Wed 21:12` · Hijri date with Gregorian gloss — `27 Rabīʿ al-awwal 1448 (09 Sep 2026)` · city · version stamp — `omp 18.3.4 · sl 0.6.2` (host from one `--version` spawn of the **running omp binary** — `process.execPath`, so it is install-agnostic (brew/npm/curl/source) and correct even for sessions resumed across an upgrade; plugin from its own package.json) — all dim. The Hijri date advances at Maghrib, not civil midnight (evening value via Aladhan `gToH`, cached per city-day; on failure it stays on today's date). | | **prayers** | All five prayers with wall times, from [aladhan](https://api.aladhan.com) (cached per local day, stale-marker on degradation). Past prayers get a dim `✓`; the next prayer is green with a countdown on its segment only — `Dhuhr 11:51 (3h 36m)`. | | **money** | API spend + token volume per window: `REPO $68.36 (1.3B)` (all-time for the current project) · `DAY` (since 00:00 local) · `7DAY` / `30DAY` (rolling, hour-aligned). Token volumes are dim, from the same sessions scan. | | **zai** | Quota pace per window: `LABEL usage%/elapsed% (pace · reset · absolute)` — e.g. `5hrs 16%/26% (30m under · 3h 43m · 11:58)`. **Provider-gated**: renders only while the active model's provider is `zai` (or the provider is unreadable); vanishes entirely on other providers. | diff --git a/package.json b/package.json index d076497..8c9ec3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@getpipher/omp-statusline", - "version": "0.6.2", + "version": "0.6.3", "description": "Four-line omp statusline widget: zai coding-plan quota (provider-gated), prayer times, hijri clock, and API-spend ledger (REPO/DAY/7DAY/30DAY, subagent-inclusive).", "keywords": [ "pi-package", diff --git a/src/index.ts b/src/index.ts index b70506c..769a693 100644 --- a/src/index.ts +++ b/src/index.ts @@ -284,7 +284,8 @@ export default function ompStatusline(pi: SlApi): void { let started = false; let warnedNoKey = false; // Clock-line stamp: sl resolves synchronously (package.json next to the source); - // omp resolves async (marker file, else one `omp --version` spawn) and re-renders. + // omp resolves async (one `--version` spawn of the RUNNING binary, + // process.execPath — install-agnostic) and re-renders. let stamp: VersionStamp = { omp: null, sl: slVersion() }; // Managed timers when the host exposes them (omp ≥18.1: a throw inside the tick diff --git a/src/versions.ts b/src/versions.ts index ac0a83b..da5d9ce 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -1,19 +1,12 @@ -// src/versions.ts — clock-line version stamp sources (v0.6.2). Both resolved once -// per process and cached: the stamp describes the RUNNING omp + the INSTALLED sl, +// src/versions.ts — clock-line version stamp sources. Both resolved once per +// process and cached: the stamp describes the RUNNING omp + the INSTALLED sl, // neither of which changes while the session lives. import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileP = promisify(execFile); -// omp rewrites this marker on every launch — it tracks the running version without -// spawning anything. NOT createRequire("@oh-my-pi/..."): omp ships as a single -// bundled binary, so no host package.json is resolvable from inside the plugin. -const LAST_CHANGELOG = join(homedir(), ".omp", "agent", "last-changelog-version"); - const SEMVER_RE = /\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?/; // Pure extraction: "18.3.4\n" → "18.3.4"; "omp version 18.3.4 (bun)" → "18.3.4"; @@ -22,20 +15,30 @@ export function extractSemver(text: string): string | null { return SEMVER_RE.exec(text.trim())?.[0] ?? null; } -// Primary source: the launch marker file. Missing/garbled → null (caller falls -// back to spawning `omp --version`). -export function readOmpVersionFile(path: string = LAST_CHANGELOG): string | null { - try { - return extractSemver(readFileSync(path, "utf8")); - } catch { - return null; - } +// Guard over `--version` output: omp prints "omp/18.4.8" (legacy: "omp version …"). +// A bare "22.11.0" — what `node --version`/`bun --version` print when execPath +// points at a plain runtime instead of omp (source/dev runs) — must NOT become +// the omp stamp, so anything without the omp prefix is rejected. +export function parseOmpVersionOutput(text: string): string | null { + const t = text.trim(); + return /^omp[/ ]/.test(t) ? extractSemver(t) : null; } -async function spawnOmpVersion(): Promise { +// Primary (and only) source: spawn THE RUNNING BINARY. Inside the plugin, +// process.execPath is whatever binary actually launched omp — brew Cellar path, +// npm-global bin, curl-installed ~/.omp/bin, asdf shim target, source build — so +// the answer is install-agnostic and stays correct for sessions resumed across +// an upgrade, where spawning PATH `omp` would report the NEW binary. Spawn +// failure (e.g. brew cleanup deleted the old Cellar mid-session) → null: the +// stamp segment is omitted upstream, never rendered as a placeholder lie. +// (v0.6.2 read ~/.omp/agent/last-changelog-version first instead — proven wrong +// in the field: omp only writes that marker when its config-gated startup +// changelog actually displays, so it goes stale across silent upgrades and is +// global state shared by every session, old and new.) +export async function spawnOmpVersion(bin: string): Promise { try { - const { stdout } = await execFileP("omp", ["--version"], { timeout: 5_000 }); - return extractSemver(stdout); + const { stdout } = await execFileP(bin, ["--version"], { timeout: 5_000 }); + return parseOmpVersionOutput(stdout); } catch { return null; } @@ -43,10 +46,10 @@ async function spawnOmpVersion(): Promise { let ompCache: Promise | undefined; -// File first (no process spawn); `omp --version` fallback, cached process-long so -// the fallback fires at most once even across sessions in the same process. +// Running-binary spawn, cached process-long so it fires at most once per +// process even across sessions in the same process. export function ompVersion(): Promise { - ompCache ??= (async () => readOmpVersionFile() ?? await spawnOmpVersion())(); + ompCache ??= spawnOmpVersion(process.execPath); return ompCache; } diff --git a/test/versions.test.ts b/test/versions.test.ts index 81a4962..47e1644 100644 --- a/test/versions.test.ts +++ b/test/versions.test.ts @@ -1,12 +1,13 @@ // test/versions.test.ts — clock-line stamp sources: pure semver extraction, the -// omp launch-marker reader (fixture files, no live ~/.omp state), and the plugin's -// own package.json resolver. +// omp `--version` output guard, the running-binary spawn (fixture scripts plus +// the test runner's own runtime as the bare-version false positive), and the +// plugin's own package.json resolver. import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { extractSemver, readOmpVersionFile, slVersion } from "../src/versions.ts"; +import { extractSemver, parseOmpVersionOutput, spawnOmpVersion, slVersion } from "../src/versions.ts"; test("extractSemver: bare marker, prefixed banner, prerelease+build, garbage", () => { assert.equal(extractSemver("18.3.4\n"), "18.3.4"); @@ -16,21 +17,38 @@ test("extractSemver: bare marker, prefixed banner, prerelease+build, garbage", ( assert.equal(extractSemver(""), null); }); -test("readOmpVersionFile: valid marker parses; garbage and missing → null", () => { +test("parseOmpVersionOutput: omp banners parse; bare runtime versions rejected", () => { + assert.equal(parseOmpVersionOutput("omp/18.4.8\n"), "18.4.8"); + assert.equal(parseOmpVersionOutput("omp version 18.3.4 (bun)"), "18.3.4"); + assert.equal(parseOmpVersionOutput("omp/1.2.3-beta.1+abc"), "1.2.3-beta.1+abc"); + assert.equal(parseOmpVersionOutput("v22.11.0\n"), null); // node --version shape + assert.equal(parseOmpVersionOutput("1.2.23\n"), null); // bun --version shape + assert.equal(parseOmpVersionOutput("no version here"), null); + assert.equal(parseOmpVersionOutput(""), null); +}); + +function writeExec(dir: string, name: string, body: string): string { + const path = join(dir, name); + writeFileSync(path, body); + chmodSync(path, 0o755); + return path; +} + +test("spawnOmpVersion: omp fixture parses; bare-version fixture rejected; missing binary → null", async () => { const dir = mkdtempSync(join(tmpdir(), "sl-versions-")); try { - const good = join(dir, "good"); - writeFileSync(good, "18.3.4\n"); - assert.equal(readOmpVersionFile(good), "18.3.4"); - const bad = join(dir, "bad"); - writeFileSync(bad, "not-a-version"); - assert.equal(readOmpVersionFile(bad), null); - assert.equal(readOmpVersionFile(join(dir, "absent")), null); + assert.equal(await spawnOmpVersion(writeExec(dir, "ompish", "#!/bin/sh\necho 'omp/9.9.9'\n")), "9.9.9"); + assert.equal(await spawnOmpVersion(writeExec(dir, "runtime", "#!/bin/sh\necho '22.11.0'\n")), null); + assert.equal(await spawnOmpVersion(join(dir, "absent")), null); } finally { rmSync(dir, { recursive: true, force: true }); } }); +test("spawnOmpVersion(process.execPath) under the test runner → null: node/bun print bare versions, not omp stamps", async () => { + assert.equal(await spawnOmpVersion(process.execPath), null); +}); + test("slVersion: resolves this package's package.json, matching the manifest version", () => { const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }; assert.equal(slVersion(), pkg.version);