tokenjam 0.5.3 → 0.5.5

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.
Files changed (2) hide show
  1. package/bin/tj.js +76 -1
  2. package/package.json +13 -3
package/bin/tj.js CHANGED
@@ -16,10 +16,27 @@
16
16
  * 3. `tj …` — an already-installed CLI on PATH
17
17
  *
18
18
  * If none are present we print actionable install guidance and exit non-zero.
19
+ *
20
+ * Note: tokenjam >=0.5.4 also ships a `tokenjam` console-script alias
21
+ * alongside `tj`, so bare `uvx tokenjam` / `pipx run tokenjam` work too. This
22
+ * wrapper keeps the explicit `--from tokenjam tj` / `--spec tokenjam tj` form
23
+ * below for back-compat with the 0.5.3 and earlier releases it also targets.
24
+ *
25
+ * Freshness (issue #111): `uv` reuses its cached tool environment and never
26
+ * re-resolves on its own, so a machine that first ran this wrapper on an old
27
+ * release keeps getting that release forever, even after newer ones hit
28
+ * PyPI. To avoid pinning stale versions indefinitely, the `uvx` branch passes
29
+ * `--refresh` at most once per 24h (tracked via a timestamp file — see
30
+ * `shouldRefresh`/`markRefreshed` below). `pipx run` isn't touched: its own
31
+ * cache already expires after ~14 days on its own. The installed-`tj` branch
32
+ * has no cache to go stale.
19
33
  */
20
34
  "use strict";
21
35
 
22
36
  const { spawnSync } = require("child_process");
37
+ const fs = require("fs");
38
+ const os = require("os");
39
+ const path = require("path");
23
40
 
24
41
  // PyPI package name vs. command name differ (`tokenjam` ships the `tj` script),
25
42
  // so ephemeral runners must be told the source package explicitly.
@@ -40,6 +57,60 @@ function runners() {
40
57
  ];
41
58
  }
42
59
 
60
+ // --- uvx cache freshness (issue #111) -------------------------------------
61
+ //
62
+ // Only the `uvx` runner needs this: `uv` caches a resolved tool environment
63
+ // and reuses it forever unless told to `--refresh`, so a returning user
64
+ // silently keeps whatever version they first resolved. Tracked with a plain
65
+ // timestamp file rather than anything fancier — this wrapper is intentionally
66
+ // dependency-free (stdlib `fs`/`os`/`path` only).
67
+
68
+ const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
69
+
70
+ function refreshCacheDir() {
71
+ const xdgCacheHome = process.env.XDG_CACHE_HOME;
72
+ const base =
73
+ xdgCacheHome && xdgCacheHome.trim()
74
+ ? xdgCacheHome
75
+ : path.join(os.homedir(), ".cache");
76
+ return path.join(base, "tokenjam-npx");
77
+ }
78
+
79
+ function refreshTimestampPath() {
80
+ return path.join(refreshCacheDir(), "last-refresh");
81
+ }
82
+
83
+ // True if it's been >24h (or we've never refreshed / can't tell). Fails
84
+ // open on any fs error — an unwritable/unreadable cache dir must never
85
+ // break the wrapper, it just means we skip the freshness nudge this run.
86
+ function shouldRefresh() {
87
+ try {
88
+ const stat = fs.statSync(refreshTimestampPath());
89
+ return Date.now() - stat.mtimeMs > REFRESH_INTERVAL_MS;
90
+ } catch {
91
+ return true; // no timestamp yet (or unreadable) => treat as stale
92
+ }
93
+ }
94
+
95
+ // Called only AFTER `uvx --refresh` has actually returned with a zero exit
96
+ // status (not before we spawn it, and not on failure). If the refresh's
97
+ // download is interrupted partway through (network drop, Ctrl-C, OOM kill),
98
+ // spawnSync never returns normally, this never runs. If it returns but uv
99
+ // exits non-zero (PyPI unreachable, partial download), the caller also
100
+ // skips this call. Either way the *next* invocation still sees a
101
+ // stale/missing timestamp and retries `--refresh`. Writing the timestamp up
102
+ // front, or unconditionally on return, would mark the cache "fresh" even
103
+ // though that refresh never completed, silently pinning a broken/partial
104
+ // environment for a full 24h. Best-effort/fail-open: swallow fs errors.
105
+ function markRefreshed() {
106
+ try {
107
+ fs.mkdirSync(refreshCacheDir(), { recursive: true });
108
+ fs.writeFileSync(refreshTimestampPath(), String(Date.now()));
109
+ } catch {
110
+ // fail open — worst case we just try to refresh again next run
111
+ }
112
+ }
113
+
43
114
  function main() {
44
115
  // Bare `npx tokenjam` IS the zero-install first run — route it to
45
116
  // `tj quickstart` (the quota report the docs promise). The branded home
@@ -51,10 +122,14 @@ function main() {
51
122
 
52
123
  for (const { bin, prefix } of runners()) {
53
124
  if (!has(bin)) continue;
54
- const result = spawnSync(bin, [...prefix, ...passthrough], {
125
+ const isUvx = bin === "uvx";
126
+ const doRefresh = isUvx && shouldRefresh();
127
+ const args = doRefresh ? ["--refresh", ...prefix] : prefix;
128
+ const result = spawnSync(bin, [...args, ...passthrough], {
55
129
  stdio: "inherit",
56
130
  });
57
131
  if (result.error) continue; // try the next runner on spawn failure
132
+ if (doRefresh && result.status === 0) markRefreshed();
58
133
  process.exit(result.status === null ? 1 : result.status);
59
134
  }
60
135
 
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "tokenjam",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Zero-install launcher for TokenJam (tj) — `npx tokenjam` runs the Python CLI via uvx/pipx and prints where your Claude Code quota actually goes, no setup.",
5
- "keywords": ["claude-code", "ccusage", "tokens", "cost", "llm", "agents", "observability", "tokenjam"],
5
+ "keywords": [
6
+ "claude-code",
7
+ "ccusage",
8
+ "tokens",
9
+ "cost",
10
+ "llm",
11
+ "agents",
12
+ "observability",
13
+ "tokenjam"
14
+ ],
6
15
  "homepage": "https://tokenjam.dev",
7
16
  "bugs": "https://github.com/Metabuilder-Labs/tokenjam/issues",
8
17
  "license": "MIT",
@@ -12,7 +21,8 @@
12
21
  "directory": "npm-wrapper"
13
22
  },
14
23
  "bin": {
15
- "tj": "bin/tj.js"
24
+ "tj": "bin/tj.js",
25
+ "tokenjam": "bin/tj.js"
16
26
  },
17
27
  "files": [
18
28
  "bin/tj.js",