santree 0.3.0 → 0.5.0
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.
- package/README.md +55 -2
- package/dist/commands/dashboard.js +538 -188
- package/dist/commands/doctor.js +164 -13
- package/dist/commands/helpers/statusline.js +10 -2
- package/dist/commands/helpers/text-editor.d.ts +13 -0
- package/dist/commands/helpers/text-editor.js +118 -0
- package/dist/commands/update.d.ts +15 -0
- package/dist/commands/update.js +72 -0
- package/dist/commands/worktree/create.d.ts +1 -0
- package/dist/commands/worktree/create.js +30 -38
- package/dist/commands/worktree/diff.d.ts +13 -0
- package/dist/commands/worktree/diff.js +76 -0
- package/dist/lib/ai.d.ts +12 -2
- package/dist/lib/ai.js +48 -14
- package/dist/lib/dashboard/DetailPanel.d.ts +9 -0
- package/dist/lib/dashboard/DetailPanel.js +235 -89
- package/dist/lib/dashboard/DiffOverlay.d.ts +50 -0
- package/dist/lib/dashboard/DiffOverlay.js +243 -0
- package/dist/lib/dashboard/IssueList.d.ts +20 -3
- package/dist/lib/dashboard/IssueList.js +74 -103
- package/dist/lib/dashboard/MultilineTextArea.js +225 -82
- package/dist/lib/dashboard/Overlays.js +1 -1
- package/dist/lib/dashboard/ReviewDetailPanel.d.ts +6 -0
- package/dist/lib/dashboard/ReviewDetailPanel.js +4 -7
- package/dist/lib/dashboard/ReviewList.d.ts +3 -1
- package/dist/lib/dashboard/ReviewList.js +3 -3
- package/dist/lib/dashboard/data.js +14 -8
- package/dist/lib/dashboard/external-editor.d.ts +12 -0
- package/dist/lib/dashboard/external-editor.js +74 -0
- package/dist/lib/dashboard/theme.d.ts +24 -0
- package/dist/lib/dashboard/theme.js +113 -0
- package/dist/lib/dashboard/types.d.ts +52 -1
- package/dist/lib/dashboard/types.js +81 -0
- package/dist/lib/git.d.ts +26 -4
- package/dist/lib/git.js +45 -33
- package/dist/lib/multiplexer/cmux.d.ts +2 -0
- package/dist/lib/multiplexer/cmux.js +97 -0
- package/dist/lib/multiplexer/index.d.ts +4 -0
- package/dist/lib/multiplexer/index.js +20 -0
- package/dist/lib/multiplexer/none.d.ts +2 -0
- package/dist/lib/multiplexer/none.js +22 -0
- package/dist/lib/multiplexer/tmux.d.ts +2 -0
- package/dist/lib/multiplexer/tmux.js +82 -0
- package/dist/lib/multiplexer/types.d.ts +23 -0
- package/dist/lib/multiplexer/types.js +3 -0
- package/dist/lib/session-signal.js +5 -8
- package/dist/lib/version.d.ts +55 -0
- package/dist/lib/version.js +224 -0
- package/package.json +1 -1
- package/shell/init.zsh.njk +45 -15
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const NOT_ACTIVE = { ok: false, reason: "not-active" };
|
|
2
|
+
export const noneMultiplexer = {
|
|
3
|
+
kind: "none",
|
|
4
|
+
isActive() {
|
|
5
|
+
return false;
|
|
6
|
+
},
|
|
7
|
+
async createWindow() {
|
|
8
|
+
return NOT_ACTIVE;
|
|
9
|
+
},
|
|
10
|
+
async selectWindow() {
|
|
11
|
+
return NOT_ACTIVE;
|
|
12
|
+
},
|
|
13
|
+
renameWindow() {
|
|
14
|
+
return NOT_ACTIVE;
|
|
15
|
+
},
|
|
16
|
+
sendCommand() {
|
|
17
|
+
return NOT_ACTIVE;
|
|
18
|
+
},
|
|
19
|
+
isSessionAlive() {
|
|
20
|
+
return false;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { shellEscape } from "./types.js";
|
|
3
|
+
function tmuxSync(cmd) {
|
|
4
|
+
try {
|
|
5
|
+
execSync(cmd, { stdio: "ignore" });
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const tmuxMultiplexer = {
|
|
13
|
+
kind: "tmux",
|
|
14
|
+
isActive() {
|
|
15
|
+
return !!process.env["TMUX"];
|
|
16
|
+
},
|
|
17
|
+
async createWindow({ name, cwd, command }) {
|
|
18
|
+
if (!this.isActive())
|
|
19
|
+
return { ok: false, reason: "not-active" };
|
|
20
|
+
const ok = tmuxSync(`tmux new-window -n ${shellEscape(name)} -c ${shellEscape(cwd)}`);
|
|
21
|
+
if (!ok)
|
|
22
|
+
return { ok: false, reason: "failed", message: "tmux new-window failed" };
|
|
23
|
+
if (command) {
|
|
24
|
+
// Brief race guard: tmux occasionally drops send-keys if it arrives before the
|
|
25
|
+
// window's shell is up. The dashboard has used this for years.
|
|
26
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
27
|
+
const sent = tmuxSync(`tmux send-keys -t ${shellEscape(name)} ${shellEscape(command)} Enter`);
|
|
28
|
+
if (!sent)
|
|
29
|
+
return { ok: false, reason: "failed", message: "tmux send-keys failed" };
|
|
30
|
+
}
|
|
31
|
+
return { ok: true };
|
|
32
|
+
},
|
|
33
|
+
async selectWindow(name) {
|
|
34
|
+
if (!this.isActive())
|
|
35
|
+
return { ok: false, reason: "not-active" };
|
|
36
|
+
const ok = tmuxSync(`tmux select-window -t ${shellEscape(name)}`);
|
|
37
|
+
return ok ? { ok: true } : { ok: false, reason: "failed" };
|
|
38
|
+
},
|
|
39
|
+
renameWindow(_currentName, newName) {
|
|
40
|
+
if (!this.isActive())
|
|
41
|
+
return { ok: false, reason: "not-active" };
|
|
42
|
+
// tmux rename-window operates on the current window when no -t is given, which
|
|
43
|
+
// matches every existing call site in santree.
|
|
44
|
+
const ok = tmuxSync(`tmux rename-window ${shellEscape(newName)}`);
|
|
45
|
+
return ok ? { ok: true } : { ok: false, reason: "failed" };
|
|
46
|
+
},
|
|
47
|
+
sendCommand(name, command) {
|
|
48
|
+
if (!this.isActive())
|
|
49
|
+
return { ok: false, reason: "not-active" };
|
|
50
|
+
const ok = tmuxSync(`tmux send-keys -t ${shellEscape(name)} ${shellEscape(command)} Enter`);
|
|
51
|
+
return ok ? { ok: true } : { ok: false, reason: "failed" };
|
|
52
|
+
},
|
|
53
|
+
isSessionAlive(ticketId) {
|
|
54
|
+
try {
|
|
55
|
+
const output = execSync('tmux list-windows -F "#{window_name}\t#{pane_pid}"', {
|
|
56
|
+
encoding: "utf-8",
|
|
57
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
58
|
+
}).trim();
|
|
59
|
+
for (const line of output.split("\n")) {
|
|
60
|
+
const [name, pidStr] = line.split("\t");
|
|
61
|
+
if (!name?.startsWith(ticketId))
|
|
62
|
+
continue;
|
|
63
|
+
if (!pidStr)
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
const ps = execSync(`pgrep -P ${pidStr} -a`, {
|
|
67
|
+
encoding: "utf-8",
|
|
68
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
69
|
+
}).trim();
|
|
70
|
+
return ps.split("\n").some((proc) => proc.includes("claude"));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// tmux not available
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type MultiplexerKind = "tmux" | "cmux" | "none";
|
|
2
|
+
export type SessionResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
reason: "not-active" | "unsupported" | "failed";
|
|
7
|
+
message?: string;
|
|
8
|
+
};
|
|
9
|
+
export interface CreateWindowOpts {
|
|
10
|
+
name: string;
|
|
11
|
+
cwd: string;
|
|
12
|
+
command?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface Multiplexer {
|
|
15
|
+
readonly kind: MultiplexerKind;
|
|
16
|
+
isActive(): boolean;
|
|
17
|
+
createWindow(opts: CreateWindowOpts): Promise<SessionResult>;
|
|
18
|
+
selectWindow(name: string): Promise<SessionResult>;
|
|
19
|
+
renameWindow(currentName: string, newName: string): SessionResult;
|
|
20
|
+
sendCommand(name: string, command: string): SessionResult;
|
|
21
|
+
isSessionAlive(ticketId: string): boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function shellEscape(s: string): string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { getMultiplexer } from "./multiplexer/index.js";
|
|
4
5
|
export function readStdin() {
|
|
5
6
|
try {
|
|
6
7
|
return fs.readFileSync(0, "utf-8");
|
|
@@ -22,7 +23,8 @@ export function extractRepoAndTicket(cwd) {
|
|
|
22
23
|
return { repoRoot, ticketId };
|
|
23
24
|
}
|
|
24
25
|
export function renameTmuxWindow(ticketId, state) {
|
|
25
|
-
|
|
26
|
+
const mux = getMultiplexer();
|
|
27
|
+
if (!mux.isActive())
|
|
26
28
|
return;
|
|
27
29
|
let name;
|
|
28
30
|
switch (state) {
|
|
@@ -36,12 +38,7 @@ export function renameTmuxWindow(ticketId, state) {
|
|
|
36
38
|
name = ticketId;
|
|
37
39
|
break;
|
|
38
40
|
}
|
|
39
|
-
|
|
40
|
-
execSync(`tmux rename-window "${name}"`, { stdio: "ignore" });
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
// Ignore tmux errors
|
|
44
|
-
}
|
|
41
|
+
mux.renameWindow("", name);
|
|
45
42
|
}
|
|
46
43
|
export function runHookScript(repoRoot, state, env) {
|
|
47
44
|
const script = path.join(repoRoot, ".santree", "hooks", `on-${state}.sh`);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export declare const CURRENT_VERSION: string;
|
|
2
|
+
export declare const SANTREE_PACKAGE = "santree";
|
|
3
|
+
export declare const CLAUDE_CODE_PACKAGE = "@anthropic-ai/claude-code";
|
|
4
|
+
export type PackageManager = "npm" | "pnpm" | "yarn";
|
|
5
|
+
/**
|
|
6
|
+
* Fetch the latest published version of an npm package from the registry.
|
|
7
|
+
* Returns null on network/parse failure so callers can fall back to cache.
|
|
8
|
+
* The npm registry accepts scoped names (`@scope/name`) verbatim in the path.
|
|
9
|
+
*/
|
|
10
|
+
export declare function fetchLatestVersionFor(pkgName: string, timeoutMs?: number): Promise<string | null>;
|
|
11
|
+
/**
|
|
12
|
+
* Returns the cached latest version of a package when fresh, otherwise refetches.
|
|
13
|
+
* Falls back to a stale cache if the network call fails.
|
|
14
|
+
*/
|
|
15
|
+
export declare function getLatestVersionFor(pkgName: string, opts?: {
|
|
16
|
+
force?: boolean;
|
|
17
|
+
}): Promise<string | null>;
|
|
18
|
+
/** Read a cached latest version without hitting the network. */
|
|
19
|
+
export declare function getCachedLatestVersionFor(pkgName: string): string | null;
|
|
20
|
+
export declare const fetchLatestVersion: (timeoutMs?: number) => Promise<string | null>;
|
|
21
|
+
export declare const getLatestVersion: (opts?: {
|
|
22
|
+
force?: boolean;
|
|
23
|
+
}) => Promise<string | null>;
|
|
24
|
+
export declare const getCachedLatestVersion: () => string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Compare semver-ish versions (major.minor.patch). Pre-release tags ignored.
|
|
27
|
+
* Returns -1 if a < b, 0 if equal, 1 if a > b.
|
|
28
|
+
*/
|
|
29
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
30
|
+
export declare function isUpdateAvailable(current: string, latest: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Read the locally installed Claude Code CLI version. Probes the resolved
|
|
33
|
+
* Claude binary first (which prefers cmux's bundled copy when running inside
|
|
34
|
+
* cmux — see lib/ai.ts:resolveClaudeBinary), then falls back to `claude` on
|
|
35
|
+
* PATH and the Anthropic installer location.
|
|
36
|
+
*/
|
|
37
|
+
export declare function getInstalledClaudeVersion(): string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Detect which package manager owns the running santree binary by inspecting
|
|
40
|
+
* the resolved path of `process.argv[1]`. Falls back to npm when uncertain.
|
|
41
|
+
*
|
|
42
|
+
* Common install paths:
|
|
43
|
+
* pnpm → ~/Library/pnpm/global/..., .../node_modules/.pnpm/santree@.../
|
|
44
|
+
* yarn → ~/.config/yarn/global/..., ~/.yarn/...
|
|
45
|
+
* npm → /usr/local/lib/node_modules/santree/..., /opt/homebrew/...
|
|
46
|
+
*/
|
|
47
|
+
export declare function detectPackageManager(): PackageManager;
|
|
48
|
+
export interface InstallCommand {
|
|
49
|
+
cmd: string;
|
|
50
|
+
args: string[];
|
|
51
|
+
display: string;
|
|
52
|
+
}
|
|
53
|
+
export declare function getInstallCommandFor(pm: PackageManager, packageSpec: string): InstallCommand;
|
|
54
|
+
/** Convenience: install the latest santree via the detected manager. */
|
|
55
|
+
export declare function getInstallCommand(pm: PackageManager): InstallCommand;
|
|
@@ -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
package/shell/init.zsh.njk
CHANGED
|
@@ -1,22 +1,45 @@
|
|
|
1
|
-
# Santree Shell Integration for Zsh
|
|
2
|
-
#
|
|
1
|
+
# Santree Shell Integration for Zsh — self-caching bootstrap
|
|
2
|
+
# ===========================================================
|
|
3
3
|
#
|
|
4
|
-
# This
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# This output is produced by `santree helpers shell-init zsh`. The santree CLI
|
|
5
|
+
# cold-starts in several seconds (Node + Pastel), so eval'ing it on every shell
|
|
6
|
+
# launch is too slow. To avoid that, the bootstrap below writes the rendered
|
|
7
|
+
# integration body to a cache file once, then sources the cache. Subsequent
|
|
8
|
+
# shells can source the cache file directly — bypassing the santree CLI
|
|
9
|
+
# entirely — and the cache self-invalidates whenever the santree binary is
|
|
10
|
+
# upgraded (see the self-validation header at the top of the cache content).
|
|
7
11
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
12
|
+
# .zshrc usage (one-liner with first-run fallback):
|
|
13
|
+
# _SI=${XDG_CACHE_HOME:-$HOME/.cache}/santree/init-zsh.zsh
|
|
14
|
+
# [[ -f $_SI ]] && source $_SI || eval "$(santree helpers shell-init zsh)"
|
|
10
15
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
+
# Behavior:
|
|
17
|
+
# - First shell after install: cache miss → runs santree (slow), writes cache.
|
|
18
|
+
# - Subsequent shells: source cache directly (fast, no santree spawn).
|
|
19
|
+
# - After `npm i -g santree` upgrade: cache mtime older than new binary,
|
|
20
|
+
# self-validation triggers a one-time regeneration in that shell.
|
|
21
|
+
|
|
22
|
+
_santree_cache="${SANTREE_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/santree}/init-zsh.zsh"
|
|
23
|
+
mkdir -p "${_santree_cache:h}"
|
|
24
|
+
|
|
25
|
+
# Write the integration body to the cache file. Single-quoted heredoc
|
|
26
|
+
# delimiter ('SANTREE_INIT_BODY_EOF__') prevents parameter expansion of the
|
|
27
|
+
# body — the variables and functions are evaluated only when the cache file
|
|
28
|
+
# is sourced, not during this write.
|
|
29
|
+
cat > "$_santree_cache" <<'SANTREE_INIT_BODY_EOF__'
|
|
30
|
+
# Santree Shell Integration for Zsh
|
|
31
|
+
# ==================================
|
|
32
|
+
# AUTO-GENERATED CACHE — do not edit. Regenerate with:
|
|
33
|
+
# eval "$(santree helpers shell-init zsh)"
|
|
16
34
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
35
|
+
# Self-validation: if the santree binary on $PATH is newer than this cache
|
|
36
|
+
# file, fall back to the slow path: re-run santree, which overwrites this
|
|
37
|
+
# cache and re-sources the fresh integration. `return` short-circuits the
|
|
38
|
+
# (now-stale) body below so its definitions don't get loaded.
|
|
39
|
+
if [[ ${commands[santree]:-} -nt ${(%):-%x} ]]; then
|
|
40
|
+
eval "$(command santree helpers shell-init zsh)"
|
|
41
|
+
return
|
|
42
|
+
fi
|
|
20
43
|
|
|
21
44
|
# Export marker so `santree doctor` can verify shell integration is loaded
|
|
22
45
|
export SANTREE_SHELL_INTEGRATION=1
|
|
@@ -172,3 +195,10 @@ if (( $+functions[compdef] )); then
|
|
|
172
195
|
compdef _santree santree
|
|
173
196
|
compdef _santree st
|
|
174
197
|
fi
|
|
198
|
+
SANTREE_INIT_BODY_EOF__
|
|
199
|
+
|
|
200
|
+
# Source the cache we just wrote so this shell gets the integration immediately.
|
|
201
|
+
# Future shells can skip the santree CLI and source the cache directly via the
|
|
202
|
+
# .zshrc one-liner shown in the comment block at the top of this output.
|
|
203
|
+
source "$_santree_cache"
|
|
204
|
+
unset _santree_cache
|