tokenjam 0.6.0 → 0.6.2

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 +182 -62
  2. package/package.json +1 -1
package/bin/tj.js CHANGED
@@ -12,8 +12,8 @@
12
12
  * command.
13
13
  *
14
14
  * Runner preference (first that exists wins):
15
- * 1. `uvx --from tokenjam tj …` — fully ephemeral, downloads nothing global
16
- * 2. `pipx run --spec tokenjam tj …`
15
+ * 1. `uvx --from tokenjam==<own version> tj …` — fully ephemeral, downloads nothing global
16
+ * 2. `pipx run --spec tokenjam==<own version> tj …`
17
17
  * 3. `tj …` — an already-installed CLI on PATH
18
18
  *
19
19
  * If none are present we print actionable install guidance and exit non-zero.
@@ -23,20 +23,27 @@
23
23
  * wrapper keeps the explicit `--from tokenjam tj` / `--spec tokenjam tj` form
24
24
  * below for back-compat with the 0.5.3 and earlier releases it also targets.
25
25
  *
26
- * Freshness (issue #111): `uv` reuses its cached tool environment and never
27
- * re-resolves on its own, so a machine that first ran this wrapper on an old
28
- * release keeps getting that release forever, even after newer ones hit
29
- * PyPI. To avoid pinning stale versions indefinitely, the `uvx` branch passes
30
- * `--refresh` at most once per 24h (tracked via a timestamp file — see
31
- * `shouldRefresh`/`markRefreshed` below). `pipx run` isn't touched: its own
32
- * cache already expires after ~14 days on its own. The installed-`tj` branch
33
- * has no cache to go stale.
26
+ * Version pinning: `uv`/`pipx` cache a resolved tool environment and reuse it
27
+ * forever unless the requested spec changes — an unpinned `--from tokenjam`
28
+ * silently keeps reusing whatever was resolved first (e.g. a prior `uv tool
29
+ * install tokenjam` at an old version), never re-resolving on its own, no
30
+ * matter how many newer releases hit PyPI since. Pinning `--from
31
+ * tokenjam==<version>` / `--spec tokenjam==<version>` to this wrapper's OWN
32
+ * version (kept in sync with the release tag by publish-npm.yml's `npm
33
+ * version ${GITHUB_REF_NAME#v}` step) forces the resolver past that shortcut,
34
+ * so `npx tokenjam` always runs the release it shipped with. If the pinned
35
+ * spec can't be resolved yet (this wrapper published slightly ahead of PyPI
36
+ * propagation), we fall back to the unpinned form rather than fail outright.
37
+ *
38
+ * Staleness note: pinning only fixes what THIS wrapper runs. A bare `tj`
39
+ * invoked directly (no `npx`) still runs whatever was separately installed
40
+ * via `uv tool install` / `pipx install` / `pip install` / Homebrew, which can
41
+ * sit on an old version indefinitely. See `warnIfShadowedByStaleInstall`
42
+ * below: detect-and-tell only, never mutates, never auto-upgrades.
34
43
  */
35
44
  "use strict";
36
45
 
37
46
  const { spawnSync } = require("child_process");
38
- const fs = require("fs");
39
- const os = require("os");
40
47
  const path = require("path");
41
48
 
42
49
  // PyPI package name vs. command name differ (`tokenjam` ships the `tj` script),
@@ -50,65 +57,177 @@ function has(bin) {
50
57
  return probe.status === 0 || probe.status === 1; // 1 = exists but no --version
51
58
  }
52
59
 
53
- function runners() {
60
+ // This wrapper's own version. `publish-npm.yml`'s wrapper-publish job runs
61
+ // `npm version ${GITHUB_REF_NAME#v}` against npm-wrapper/package.json before
62
+ // `npm publish`, so whatever version this file ships inside always matches
63
+ // the tokenjam release it was cut alongside — safe to read at runtime as the
64
+ // version to pin the Python side to.
65
+ function ownVersion() {
66
+ try {
67
+ return require(path.join(__dirname, "..", "package.json")).version;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function runners(version) {
74
+ const pinnedSpec = version ? `${PACKAGE}==${version}` : null;
54
75
  return [
55
- { bin: "uvx", prefix: ["--from", PACKAGE, COMMAND] },
56
- { bin: "pipx", prefix: ["run", "--spec", PACKAGE, COMMAND] },
57
- { bin: COMMAND, prefix: [] }, // already installed on PATH
76
+ {
77
+ bin: "uvx",
78
+ pinnedPrefix: pinnedSpec ? ["--from", pinnedSpec, COMMAND] : null,
79
+ prefix: ["--from", PACKAGE, COMMAND],
80
+ },
81
+ {
82
+ bin: "pipx",
83
+ pinnedPrefix: pinnedSpec ? ["run", "--spec", pinnedSpec, COMMAND] : null,
84
+ prefix: ["run", "--spec", PACKAGE, COMMAND],
85
+ },
86
+ { bin: COMMAND, pinnedPrefix: null, prefix: [] }, // already installed on PATH, nothing to pin
58
87
  ];
59
88
  }
60
89
 
61
- // --- uvx cache freshness (issue #111) -------------------------------------
62
- //
63
- // Only the `uvx` runner needs this: `uv` caches a resolved tool environment
64
- // and reuses it forever unless told to `--refresh`, so a returning user
65
- // silently keeps whatever version they first resolved. Tracked with a plain
66
- // timestamp file rather than anything fancier — this wrapper is intentionally
67
- // dependency-free (stdlib `fs`/`os`/`path` only).
68
-
69
- const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
70
-
71
- function refreshCacheDir() {
72
- const xdgCacheHome = process.env.XDG_CACHE_HOME;
73
- const base =
74
- xdgCacheHome && xdgCacheHome.trim()
75
- ? xdgCacheHome
76
- : path.join(os.homedir(), ".cache");
77
- return path.join(base, "tokenjam-npx");
90
+ // Cheap, side-effect-free resolution probe: does `<bin> <args> --version`
91
+ // succeed? Used to decide, before the real invocation, whether the pinned
92
+ // package spec actually resolves on this runner — falls back to the
93
+ // unpinned prefix when it doesn't (wrapper published ahead of PyPI
94
+ // propagation). Unlike `has()`, this requires an exact status 0: `uv`
95
+ // exits 1 both for "resolution failed" AND for "binary ran fine but
96
+ // doesn't understand --version", so treating 1 as success here would
97
+ // silently paper over real resolution failures (verified: `uvx --from
98
+ // tokenjam==<bogus> tj --version` also exits 1, indistinguishable from the
99
+ // unsupported-flag case `has()` is built around).
100
+ function resolves(bin, args) {
101
+ const probe = spawnSync(bin, [...args, "--version"], { stdio: "ignore" });
102
+ return probe.status === 0;
78
103
  }
79
104
 
80
- function refreshTimestampPath() {
81
- return path.join(refreshCacheDir(), "last-refresh");
82
- }
105
+ // --- stale shadowing install detection ------------------------------------
106
+ //
107
+ // Detect-and-tell only, per design: this never mutates anything and never
108
+ // upgrades on the user's behalf, in interactive or non-interactive/CI
109
+ // contexts alike. It's a best-effort nudge — any detection failure (missing
110
+ // binary, unexpected output, timeout) is swallowed and skipped silently; it
111
+ // must never break or slow down the primary command above by much.
83
112
 
84
- // True if it's been >24h (or we've never refreshed / can't tell). Fails
85
- // open on any fs error — an unwritable/unreadable cache dir must never
86
- // break the wrapper, it just means we skip the freshness nudge this run.
87
- function shouldRefresh() {
113
+ const DETECT_TIMEOUT_MS = 2000;
114
+
115
+ function safeSpawn(bin, args) {
88
116
  try {
89
- const stat = fs.statSync(refreshTimestampPath());
90
- return Date.now() - stat.mtimeMs > REFRESH_INTERVAL_MS;
117
+ return spawnSync(bin, args, {
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ timeout: DETECT_TIMEOUT_MS,
120
+ encoding: "utf8",
121
+ });
91
122
  } catch {
92
- return true; // no timestamp yet (or unreadable) => treat as stale
123
+ return null;
124
+ }
125
+ }
126
+
127
+ function versionParts(v) {
128
+ return String(v)
129
+ .trim()
130
+ .split(".")
131
+ .map((n) => parseInt(n, 10) || 0);
132
+ }
133
+
134
+ function isOlder(a, b) {
135
+ const pa = versionParts(a);
136
+ const pb = versionParts(b);
137
+ const len = Math.max(pa.length, pb.length);
138
+ for (let i = 0; i < len; i++) {
139
+ const x = pa[i] || 0;
140
+ const y = pb[i] || 0;
141
+ if (x !== y) return x < y;
93
142
  }
143
+ return false;
94
144
  }
95
145
 
96
- // Called only AFTER `uvx --refresh` has actually returned with a zero exit
97
- // status (not before we spawn it, and not on failure). If the refresh's
98
- // download is interrupted partway through (network drop, Ctrl-C, OOM kill),
99
- // spawnSync never returns normally, this never runs. If it returns but uv
100
- // exits non-zero (PyPI unreachable, partial download), the caller also
101
- // skips this call. Either way the *next* invocation still sees a
102
- // stale/missing timestamp and retries `--refresh`. Writing the timestamp up
103
- // front, or unconditionally on return, would mark the cache "fresh" even
104
- // though that refresh never completed, silently pinning a broken/partial
105
- // environment for a full 24h. Best-effort/fail-open: swallow fs errors.
106
- function markRefreshed() {
146
+ // Each detector below is skipped up front via `has()` when its own binary
147
+ // isn't even on PATH, so a machine without e.g. Homebrew never pays for a
148
+ // `brew list` spawn.
149
+
150
+ function detectUvTool() {
151
+ if (!has("uv")) return null;
152
+ const result = safeSpawn("uv", ["tool", "list"]);
153
+ if (!result || result.status !== 0 || !result.stdout) return null;
154
+ const match = result.stdout.match(/^tokenjam\s+v?(\S+)/m);
155
+ if (!match) return null;
156
+ return {
157
+ method: "uv tool",
158
+ version: match[1],
159
+ upgradeCmd: "uv tool upgrade tokenjam",
160
+ };
161
+ }
162
+
163
+ function detectPipx() {
164
+ if (!has("pipx")) return null;
165
+ const result = safeSpawn("pipx", ["list", "--json"]);
166
+ if (!result || result.status !== 0 || !result.stdout) return null;
107
167
  try {
108
- fs.mkdirSync(refreshCacheDir(), { recursive: true });
109
- fs.writeFileSync(refreshTimestampPath(), String(Date.now()));
168
+ const data = JSON.parse(result.stdout);
169
+ const venv = data.venvs && data.venvs[PACKAGE];
170
+ const version =
171
+ venv &&
172
+ venv.metadata &&
173
+ venv.metadata.main_package &&
174
+ venv.metadata.main_package.package_version;
175
+ if (!version) return null;
176
+ return { method: "pipx", version, upgradeCmd: "pipx upgrade tokenjam" };
110
177
  } catch {
111
- // fail open — worst case we just try to refresh again next run
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function detectPip() {
183
+ for (const pipBin of ["pip3", "pip"]) {
184
+ if (!has(pipBin)) continue;
185
+ const result = safeSpawn(pipBin, ["show", PACKAGE]);
186
+ if (!result || result.status !== 0 || !result.stdout) continue;
187
+ const match = result.stdout.match(/^Version:\s*(\S+)/m);
188
+ if (!match) continue;
189
+ // Covers both a plain `pip install` and `pip install --user` — pip
190
+ // doesn't distinguish the two in `pip show` output, and either way the
191
+ // fix command is the same.
192
+ return {
193
+ method: "pip",
194
+ version: match[1],
195
+ upgradeCmd: `${pipBin} install --upgrade ${PACKAGE}`,
196
+ };
197
+ }
198
+ return null;
199
+ }
200
+
201
+ function detectHomebrew() {
202
+ if (!has("brew")) return null;
203
+ const result = safeSpawn("brew", ["list", "--versions", PACKAGE]);
204
+ if (!result || result.status !== 0 || !result.stdout) return null;
205
+ const match = result.stdout.trim().match(/^tokenjam\s+(\S+)/);
206
+ if (!match) return null;
207
+ return {
208
+ method: "Homebrew",
209
+ version: match[1],
210
+ upgradeCmd: "brew upgrade tokenjam",
211
+ };
212
+ }
213
+
214
+ function warnIfShadowedByStaleInstall(wrapperVersion) {
215
+ if (!wrapperVersion) return;
216
+ const detectors = [detectUvTool, detectPipx, detectPip, detectHomebrew];
217
+ for (const detect of detectors) {
218
+ let found = null;
219
+ try {
220
+ found = detect();
221
+ } catch {
222
+ found = null;
223
+ }
224
+ if (!found || !isOlder(found.version, wrapperVersion)) continue;
225
+ process.stderr.write(
226
+ "\n" +
227
+ `Note: a ${found.method} install of tokenjam is at v${found.version}, older than v${wrapperVersion} run here.\n` +
228
+ `Upgrade it with: ${found.upgradeCmd}\n`
229
+ );
230
+ return; // one line is enough — first stale install found wins
112
231
  }
113
232
  }
114
233
 
@@ -127,17 +246,18 @@ function main() {
127
246
  ? process.env
128
247
  : { ...process.env, TJ_NPX_ZERO_INSTALL_REPORT: "1" };
129
248
 
130
- for (const { bin, prefix } of runners()) {
249
+ const version = ownVersion();
250
+
251
+ for (const { bin, pinnedPrefix, prefix } of runners(version)) {
131
252
  if (!has(bin)) continue;
132
- const isUvx = bin === "uvx";
133
- const doRefresh = isUvx && shouldRefresh();
134
- const args = doRefresh ? ["--refresh", ...prefix] : prefix;
253
+ const args =
254
+ pinnedPrefix && resolves(bin, pinnedPrefix) ? pinnedPrefix : prefix;
135
255
  const result = spawnSync(bin, [...args, ...passthrough], {
136
256
  stdio: "inherit",
137
257
  env: childEnv,
138
258
  });
139
259
  if (result.error) continue; // try the next runner on spawn failure
140
- if (doRefresh && result.status === 0) markRefreshed();
260
+ warnIfShadowedByStaleInstall(version);
141
261
  process.exit(result.status === null ? 1 : result.status);
142
262
  }
143
263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenjam",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Zero-install launcher for TokenJam (tj): npx tokenjam runs the Python CLI via uvx/pipx and reports the recurring mistakes your AI agent keeps repeating, no setup required.",
5
5
  "keywords": [
6
6
  "claude-code",