santree 0.4.0 → 0.5.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.
@@ -0,0 +1,224 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import * as https from "https";
5
+ import { execSync } from "child_process";
6
+ import { createRequire } from "module";
7
+ import { resolveClaudeBinary } from "./ai.js";
8
+ const require = createRequire(import.meta.url);
9
+ const pkg = require("../../package.json");
10
+ export const CURRENT_VERSION = pkg.version;
11
+ export const SANTREE_PACKAGE = "santree";
12
+ export const CLAUDE_CODE_PACKAGE = "@anthropic-ai/claude-code";
13
+ const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h
14
+ function configDir() {
15
+ const xdg = process.env["XDG_CONFIG_HOME"];
16
+ return path.join(xdg ?? path.join(os.homedir(), ".config"), "santree");
17
+ }
18
+ function cachePath() {
19
+ return path.join(configDir(), "version-cache.json");
20
+ }
21
+ function isCacheEntry(v) {
22
+ return (typeof v === "object" &&
23
+ v !== null &&
24
+ typeof v.latest === "string" &&
25
+ typeof v.fetchedAt === "number");
26
+ }
27
+ function readCache() {
28
+ try {
29
+ const raw = fs.readFileSync(cachePath(), "utf-8");
30
+ const parsed = JSON.parse(raw);
31
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
32
+ return {};
33
+ // Migrate old single-package shape `{ latest, fetchedAt }` → `{ santree: {...} }`
34
+ if (isCacheEntry(parsed)) {
35
+ return { [SANTREE_PACKAGE]: parsed };
36
+ }
37
+ const out = {};
38
+ for (const [k, v] of Object.entries(parsed)) {
39
+ if (isCacheEntry(v))
40
+ out[k] = v;
41
+ }
42
+ return out;
43
+ }
44
+ catch {
45
+ return {};
46
+ }
47
+ }
48
+ function writeCacheEntry(pkgName, latest) {
49
+ try {
50
+ fs.mkdirSync(configDir(), { recursive: true });
51
+ const cache = readCache();
52
+ cache[pkgName] = { latest, fetchedAt: Date.now() };
53
+ fs.writeFileSync(cachePath(), JSON.stringify(cache));
54
+ }
55
+ catch {
56
+ // best-effort — version check is non-critical
57
+ }
58
+ }
59
+ /**
60
+ * Fetch the latest published version of an npm package from the registry.
61
+ * Returns null on network/parse failure so callers can fall back to cache.
62
+ * The npm registry accepts scoped names (`@scope/name`) verbatim in the path.
63
+ */
64
+ export function fetchLatestVersionFor(pkgName, timeoutMs = 2000) {
65
+ return new Promise((resolve) => {
66
+ const req = https.get(`https://registry.npmjs.org/${pkgName}/latest`, { headers: { Accept: "application/json" }, timeout: timeoutMs }, (res) => {
67
+ if (res.statusCode !== 200) {
68
+ res.resume();
69
+ resolve(null);
70
+ return;
71
+ }
72
+ let body = "";
73
+ res.setEncoding("utf-8");
74
+ res.on("data", (chunk) => (body += chunk));
75
+ res.on("end", () => {
76
+ try {
77
+ const data = JSON.parse(body);
78
+ const v = typeof data?.version === "string" ? data.version : null;
79
+ if (v)
80
+ writeCacheEntry(pkgName, v);
81
+ resolve(v);
82
+ }
83
+ catch {
84
+ resolve(null);
85
+ }
86
+ });
87
+ });
88
+ req.on("error", () => resolve(null));
89
+ req.on("timeout", () => {
90
+ req.destroy();
91
+ resolve(null);
92
+ });
93
+ });
94
+ }
95
+ /**
96
+ * Returns the cached latest version of a package when fresh, otherwise refetches.
97
+ * Falls back to a stale cache if the network call fails.
98
+ */
99
+ export async function getLatestVersionFor(pkgName, opts) {
100
+ const cache = readCache()[pkgName];
101
+ if (!opts?.force && cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) {
102
+ return cache.latest;
103
+ }
104
+ const fresh = await fetchLatestVersionFor(pkgName);
105
+ return fresh ?? cache?.latest ?? null;
106
+ }
107
+ /** Read a cached latest version without hitting the network. */
108
+ export function getCachedLatestVersionFor(pkgName) {
109
+ return readCache()[pkgName]?.latest ?? null;
110
+ }
111
+ // ── Santree-specific shorthands (preserve existing call sites) ───────
112
+ export const fetchLatestVersion = (timeoutMs) => fetchLatestVersionFor(SANTREE_PACKAGE, timeoutMs);
113
+ export const getLatestVersion = (opts) => getLatestVersionFor(SANTREE_PACKAGE, opts);
114
+ export const getCachedLatestVersion = () => getCachedLatestVersionFor(SANTREE_PACKAGE);
115
+ /**
116
+ * Compare semver-ish versions (major.minor.patch). Pre-release tags ignored.
117
+ * Returns -1 if a < b, 0 if equal, 1 if a > b.
118
+ */
119
+ export function compareVersions(a, b) {
120
+ const parse = (v) => {
121
+ const stripped = v.replace(/^v/, "").split("-")[0] ?? "0";
122
+ return stripped.split(".").map((n) => parseInt(n, 10) || 0);
123
+ };
124
+ const pa = parse(a);
125
+ const pb = parse(b);
126
+ for (let i = 0; i < 3; i++) {
127
+ const ai = pa[i] ?? 0;
128
+ const bi = pb[i] ?? 0;
129
+ if (ai !== bi)
130
+ return ai < bi ? -1 : 1;
131
+ }
132
+ return 0;
133
+ }
134
+ export function isUpdateAvailable(current, latest) {
135
+ return compareVersions(current, latest) < 0;
136
+ }
137
+ /**
138
+ * Read the locally installed Claude Code CLI version. Probes the resolved
139
+ * Claude binary first (which prefers cmux's bundled copy when running inside
140
+ * cmux — see lib/ai.ts:resolveClaudeBinary), then falls back to `claude` on
141
+ * PATH and the Anthropic installer location.
142
+ */
143
+ export function getInstalledClaudeVersion() {
144
+ const resolved = resolveClaudeBinary();
145
+ const candidates = [
146
+ resolved,
147
+ "claude",
148
+ path.join(os.homedir(), ".claude", "local", "claude"),
149
+ ].filter((b) => b !== null);
150
+ const seen = new Set();
151
+ for (const bin of candidates) {
152
+ if (seen.has(bin))
153
+ continue;
154
+ seen.add(bin);
155
+ try {
156
+ const out = execSync(`${bin} --version`, {
157
+ encoding: "utf-8",
158
+ stdio: ["pipe", "pipe", "pipe"],
159
+ }).trim();
160
+ const v = out.split(/\s+/)[0];
161
+ if (v)
162
+ return v;
163
+ }
164
+ catch {
165
+ // try next
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+ /**
171
+ * Detect which package manager owns the running santree binary by inspecting
172
+ * the resolved path of `process.argv[1]`. Falls back to npm when uncertain.
173
+ *
174
+ * Common install paths:
175
+ * pnpm → ~/Library/pnpm/global/..., .../node_modules/.pnpm/santree@.../
176
+ * yarn → ~/.config/yarn/global/..., ~/.yarn/...
177
+ * npm → /usr/local/lib/node_modules/santree/..., /opt/homebrew/...
178
+ */
179
+ export function detectPackageManager() {
180
+ const candidates = [process.argv[1]].filter((p) => Boolean(p));
181
+ for (const candidate of candidates) {
182
+ let resolved = candidate;
183
+ try {
184
+ resolved = fs.realpathSync(candidate);
185
+ }
186
+ catch {
187
+ // keep original — still useful for path matching
188
+ }
189
+ const haystack = `${candidate}|${resolved}`;
190
+ if (/[\\/](?:pnpm|\.pnpm)[\\/]/i.test(haystack))
191
+ return "pnpm";
192
+ if (/[\\/]\.yarn[\\/]/i.test(haystack) || /[\\/]yarn[\\/]global[\\/]/i.test(haystack)) {
193
+ return "yarn";
194
+ }
195
+ }
196
+ return "npm";
197
+ }
198
+ export function getInstallCommandFor(pm, packageSpec) {
199
+ switch (pm) {
200
+ case "pnpm":
201
+ return {
202
+ cmd: "pnpm",
203
+ args: ["add", "-g", packageSpec],
204
+ display: `pnpm add -g ${packageSpec}`,
205
+ };
206
+ case "yarn":
207
+ return {
208
+ cmd: "yarn",
209
+ args: ["global", "add", packageSpec],
210
+ display: `yarn global add ${packageSpec}`,
211
+ };
212
+ case "npm":
213
+ default:
214
+ return {
215
+ cmd: "npm",
216
+ args: ["install", "-g", packageSpec],
217
+ display: `npm install -g ${packageSpec}`,
218
+ };
219
+ }
220
+ }
221
+ /** Convenience: install the latest santree via the detected manager. */
222
+ export function getInstallCommand(pm) {
223
+ return getInstallCommandFor(pm, "santree@latest");
224
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",