Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,37 @@ jobs:

- name: Run local CI gate
run: npm run ci:local

node20-contract:
name: node20-contract
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v6
with:
# Dashboard build tooling has a newer floor than the shipped CLI.
node-version: 24
cache: npm
cache-dependency-path: dashboard/package-lock.json

- name: Prepare generated dashboard fixtures
run: npm ci --prefix dashboard && npm run dashboard:build

- uses: actions/setup-node@v6
with:
# Exact declared CLI floor; do not replace with floating `20`.
node-version: 20.18.1
cache: npm
cache-dependency-path: package-lock.json

- name: Install SQLite fixture dependency
run: sudo apt-get update && sudo apt-get install -y sqlite3

- name: Install root dependencies
run: npm ci

- name: Run root suite at the declared minimum
run: npm test
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Guidance for Claude Code working in this repository. Every line here is loaded i

Token Tracker is a local-first AI token usage tracker.

- **CLI** (`src/`, CommonJS, Node ≥20) — entry `bin/tracker.js` → `src/cli.js`. `serve` runs a local HTTP server on `:7680`, `sync` parses logs into `~/.tokentracker/tracker/queue.jsonl`.
- **CLI** (`src/`, CommonJS, Node ≥20.18.1) — entry `bin/tracker.js` → `src/cli.js`. `serve` runs a local HTTP server on `:7680`, `sync` parses logs into `~/.tokentracker/tracker/queue.jsonl`.
- **Dashboard** (`dashboard/`, React 18 + Vite 7 + TS strict + Tailwind) — built to `dashboard/dist/`, served by the CLI on localhost. Local-only: there is no hosted deployment.
- **macOS app** (`TokenTrackerBar/`, Swift 5.9, XcodeGen) — menu bar + WidgetKit. `EmbeddedServer/` bundles the CLI runtime + built dashboard so the `.app` is self-contained.
- **Windows app** (`TokenTrackerWin/`, .NET 8 WinForms + WPF + WebView2) — system-tray counterpart of the macOS app. Launches the bundled CLI `serve` on a dynamic loopback port (avoids the DoSvc-held `:7680`), hosts the dashboard in WebView2, registers the `tokentracker://` deep-link for OAuth. Built `EmbeddedServer/` (Node + CLI + dashboard) is bundled by `scripts/bundle-node.ps1` so the `.exe` is self-contained. Dashboard adaptations are gated behind `isNativeWindowsApp()` (`dashboard/src/lib/native-bridge.js`) so macOS/web paths are untouched.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Look at how Claude Code, Codex, or Gemini are wired in for reference — they're

## Code Style

- **CLI (`src/`)**: CommonJS, Node 20+, no transpilation. Match the existing style.
- **CLI (`src/`)**: CommonJS, Node 20.18.1+, no transpilation. Match the existing style.
- **Dashboard (`dashboard/`)**: TypeScript strict, React 18, ESM, Tailwind. Match the existing style.
- **macOS (`TokenTrackerBar/`)**: Swift 5.9, SwiftUI + AppKit. Match the existing style.
- No linter wars. Be reasonable.
Expand Down
4 changes: 2 additions & 2 deletions TokenTrackerBar/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ targets:
PRODUCT_NAME: TokenTrackerBar
INFOPLIST_VALUES: |
LSUIElement = YES
MARKETING_VERSION: "0.39.50"
MARKETING_VERSION: "0.39.51"
CURRENT_PROJECT_VERSION: "1"
SWIFT_EMIT_LOC_STRINGS: "YES"
CODE_SIGN_STYLE: Automatic
Expand Down Expand Up @@ -79,7 +79,7 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.tokentracker.bar.widget
PRODUCT_NAME: TokenTrackerWidget
MARKETING_VERSION: "0.39.50"
MARKETING_VERSION: "0.39.51"
CURRENT_PROJECT_VERSION: "1"
MACOSX_DEPLOYMENT_TARGET: "14.0"
SWIFT_EMIT_LOC_STRINGS: "YES"
Expand Down
2 changes: 1 addition & 1 deletion TokenTrackerWin/TokenTrackerWin.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>assets\trayicon.ico</ApplicationIcon>
<!-- Keep in sync with package.json / project.yml on release. -->
<Version>0.39.50</Version>
<Version>0.39.51</Version>
<Product>TokenTracker</Product>
<Company>TokenTracker</Company>
</PropertyGroup>
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ipv9/tokentracker-cli",
"version": "0.39.50",
"version": "0.39.51",
"description": "Local-first token and cost dashboard for 22 AI coding tools, including Claude Code, Codex, Cursor, Gemini, Kiro, OpenCode, OpenClaw, Copilot, Antigravity, Zed, and Goose.",
"main": "src/cli.js",
"bin": {
Expand Down Expand Up @@ -53,7 +53,7 @@
},
"dependencies": {
"@mongodb-js/zstd": "^2.0.1",
"undici": "^7.27.1"
"undici": "^7.29.0"
},
"devDependencies": {
"@sourcegraph/scip-typescript": "^0.3.6",
Expand Down Expand Up @@ -89,6 +89,6 @@
],
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=20.18.1"
}
}
65 changes: 35 additions & 30 deletions test/fixtures/parser-conformance/hermes.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
// profile directories rather than a JSONL file, and the note said a fixture
// "needs a real schema, not a JSONL line". It does — so this builds one.
//
// `node:sqlite` ships with Node 22+, which is already the floor for the
// dashboard tests, and `readHermesSessions` falls back to it when the sqlite3
// CLI is absent. If neither is available the harness reports the fixture
// produced no rows rather than passing quietly.
// The package supports Node 20, where `node:sqlite` is unavailable. Build the
// deterministic fixture through the sqlite3 CLI instead—the same primary reader
// used by production before its newer-runtime `node:sqlite` fallback. The
// minimum-Node CI job installs sqlite3 explicitly so this fixture cannot skip.
//
// Schema taken from the parser's own query (rollout.js:3216), not guessed:
// SELECT id, model, started_at, ended_at, input_tokens, output_tokens,
Expand All @@ -22,7 +22,7 @@

const fs = require("node:fs");
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite");
const { spawnSync } = require("node:child_process");

const SCHEMA = `
CREATE TABLE sessions (
Expand All @@ -44,33 +44,38 @@ const SCHEMA = `
const STARTED = Math.floor(Date.parse("2026-05-14T09:12:30.000Z") / 1000);
const ENDED = Math.floor(Date.parse("2026-05-14T09:20:00.000Z") / 1000);

function sqlValue(value) {
if (value === null || value === undefined) return "NULL";
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error(`non-finite SQLite fixture value: ${value}`);
return String(value);
}
return `'${String(value).replace(/'/g, "''")}'`;
}

function writeDb(dbPath, rows) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
try {
db.exec(SCHEMA);
const insert = db.prepare(
`INSERT INTO sessions
(id, model, started_at, ended_at, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens, message_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const r of rows) {
insert.run(
r.id,
r.model,
r.started_at,
r.ended_at,
r.input_tokens,
r.output_tokens,
r.cache_read_tokens,
r.cache_write_tokens,
r.reasoning_tokens,
r.message_count,
);
}
} finally {
db.close();
const columns = [
"id",
"model",
"started_at",
"ended_at",
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"reasoning_tokens",
"message_count",
];
const inserts = rows.map((row) => (
`INSERT INTO sessions (${columns.join(", ")}) VALUES (${columns.map((column) => sqlValue(row[column])).join(", ")});`
));
const result = spawnSync("sqlite3", [dbPath], {
input: `${SCHEMA}\n${inserts.join("\n")}\n`,
encoding: "utf8",
});
if (result.error || result.status !== 0) {
throw new Error(`sqlite3 fixture build failed: ${result.error?.message || result.stderr || `status ${result.status}`}`);
}
}

Expand Down
39 changes: 39 additions & 0 deletions test/p0-runtime-contract.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use strict";

const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { test } = require("node:test");

const ROOT = path.join(__dirname, "..");

function read(relative) {
return fs.readFileSync(path.join(ROOT, relative), "utf8");
}

test("runtime contract pins the patched undici line and its true Node floor", () => {
const pkg = JSON.parse(read("package.json"));
const lock = JSON.parse(read("package-lock.json"));
assert.equal(pkg.dependencies.undici, "^7.29.0");
assert.equal(pkg.engines.node, ">=20.18.1");
assert.equal(lock.packages[""].dependencies.undici, "^7.29.0");
assert.equal(lock.packages[""].engines.node, ">=20.18.1");
const installed = lock.packages["node_modules/undici"];
assert.equal(installed.version, "7.29.0");
assert.equal(installed.engines.node, ">=20.18.1");
assert.match(read("CLAUDE.md"), /Node ≥20\.18\.1/);
assert.match(read("CONTRIBUTING.md"), /Node 20\.18\.1\+/);
});

test("CI continuously runs the root suite at the exact minimum Node version", () => {
const workflow = read(".github/workflows/ci.yml");
assert.match(workflow, /node20-contract:/);
assert.match(workflow, /node-version:\s*20\.18\.1/);
assert.match(workflow, /run:\s*npm test/);
});

test("parser conformance fixture does not require node:sqlite at module load", () => {
const fixture = read("test/fixtures/parser-conformance/hermes.cjs");
assert.doesNotMatch(fixture, /require\(["']node:sqlite["']\)/);
assert.match(fixture, /sqlite3/);
});