@colmbus72/yeehaw 0.1.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/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/app.d.ts +1 -0
- package/dist/app.js +414 -0
- package/dist/components/BarnHeader.d.ts +6 -0
- package/dist/components/BarnHeader.js +21 -0
- package/dist/components/BottomBar.d.ts +16 -0
- package/dist/components/BottomBar.js +7 -0
- package/dist/components/Header.d.ts +8 -0
- package/dist/components/Header.js +83 -0
- package/dist/components/HelpOverlay.d.ts +7 -0
- package/dist/components/HelpOverlay.js +17 -0
- package/dist/components/List.d.ts +17 -0
- package/dist/components/List.js +53 -0
- package/dist/components/Markdown.d.ts +8 -0
- package/dist/components/Markdown.js +23 -0
- package/dist/components/Panel.d.ts +10 -0
- package/dist/components/Panel.js +5 -0
- package/dist/components/PathInput.d.ts +9 -0
- package/dist/components/PathInput.js +141 -0
- package/dist/components/ScrollableMarkdown.d.ts +11 -0
- package/dist/components/ScrollableMarkdown.js +56 -0
- package/dist/components/StatusBar.d.ts +5 -0
- package/dist/components/StatusBar.js +20 -0
- package/dist/components/TextArea.d.ts +17 -0
- package/dist/components/TextArea.js +140 -0
- package/dist/components/index.d.ts +5 -0
- package/dist/components/index.js +5 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/useConfig.d.ts +11 -0
- package/dist/hooks/useConfig.js +36 -0
- package/dist/hooks/useRemoteYeehaw.d.ts +13 -0
- package/dist/hooks/useRemoteYeehaw.js +49 -0
- package/dist/hooks/useSessions.d.ts +11 -0
- package/dist/hooks/useSessions.js +46 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +34 -0
- package/dist/lib/config.d.ts +27 -0
- package/dist/lib/config.js +150 -0
- package/dist/lib/detection.d.ts +16 -0
- package/dist/lib/detection.js +41 -0
- package/dist/lib/editor.d.ts +5 -0
- package/dist/lib/editor.js +35 -0
- package/dist/lib/errors.d.ts +28 -0
- package/dist/lib/errors.js +48 -0
- package/dist/lib/git.d.ts +11 -0
- package/dist/lib/git.js +73 -0
- package/dist/lib/github.d.ts +43 -0
- package/dist/lib/github.js +111 -0
- package/dist/lib/hotkeys.d.ts +27 -0
- package/dist/lib/hotkeys.js +92 -0
- package/dist/lib/index.d.ts +10 -0
- package/dist/lib/index.js +10 -0
- package/dist/lib/livestock.d.ts +51 -0
- package/dist/lib/livestock.js +233 -0
- package/dist/lib/mcp-validation.d.ts +33 -0
- package/dist/lib/mcp-validation.js +62 -0
- package/dist/lib/paths.d.ts +8 -0
- package/dist/lib/paths.js +28 -0
- package/dist/lib/shell.d.ts +34 -0
- package/dist/lib/shell.js +61 -0
- package/dist/lib/ssh.d.ts +15 -0
- package/dist/lib/ssh.js +77 -0
- package/dist/lib/tmux-config.d.ts +3 -0
- package/dist/lib/tmux-config.js +42 -0
- package/dist/lib/tmux.d.ts +32 -0
- package/dist/lib/tmux.js +397 -0
- package/dist/mcp-server.d.ts +23 -0
- package/dist/mcp-server.js +825 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.js +2 -0
- package/dist/views/BarnContext.d.ts +22 -0
- package/dist/views/BarnContext.js +252 -0
- package/dist/views/GlobalDashboard.d.ts +16 -0
- package/dist/views/GlobalDashboard.js +253 -0
- package/dist/views/Home.d.ts +11 -0
- package/dist/views/Home.js +27 -0
- package/dist/views/IssuesView.d.ts +7 -0
- package/dist/views/IssuesView.js +157 -0
- package/dist/views/LivestockDetailView.d.ts +11 -0
- package/dist/views/LivestockDetailView.js +140 -0
- package/dist/views/LogsView.d.ts +8 -0
- package/dist/views/LogsView.js +84 -0
- package/dist/views/NightSkyView.d.ts +5 -0
- package/dist/views/NightSkyView.js +441 -0
- package/dist/views/ProjectContext.d.ts +18 -0
- package/dist/views/ProjectContext.js +333 -0
- package/dist/views/Projects.d.ts +8 -0
- package/dist/views/Projects.js +20 -0
- package/dist/views/WikiView.d.ts +8 -0
- package/dist/views/WikiView.js +138 -0
- package/dist/views/index.d.ts +2 -0
- package/dist/views/index.js +2 -0
- package/package.json +65 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface GitHubIssue {
|
|
2
|
+
number: number;
|
|
3
|
+
title: string;
|
|
4
|
+
state: 'open' | 'closed';
|
|
5
|
+
author: string;
|
|
6
|
+
labels: string[];
|
|
7
|
+
createdAt: string;
|
|
8
|
+
updatedAt: string;
|
|
9
|
+
commentsCount: number;
|
|
10
|
+
body: string | null;
|
|
11
|
+
url: string;
|
|
12
|
+
}
|
|
13
|
+
export interface GitHubRepo {
|
|
14
|
+
owner: string;
|
|
15
|
+
repo: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse a GitHub URL to extract owner and repo.
|
|
19
|
+
* Handles formats like:
|
|
20
|
+
* - https://github.com/owner/repo
|
|
21
|
+
* - https://github.com/owner/repo.git
|
|
22
|
+
* - git@github.com:owner/repo.git
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseGitHubUrl(url: string): GitHubRepo | null;
|
|
25
|
+
/**
|
|
26
|
+
* Check if gh CLI is installed and authenticated.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isGhCliAvailable(): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Fetch issues for a GitHub repository using gh CLI.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fetchGitHubIssues(owner: string, repo: string, options?: {
|
|
33
|
+
state?: 'open' | 'closed' | 'all';
|
|
34
|
+
limit?: number;
|
|
35
|
+
}): GitHubIssue[];
|
|
36
|
+
/**
|
|
37
|
+
* Fetch a single issue with full details.
|
|
38
|
+
*/
|
|
39
|
+
export declare function fetchGitHubIssue(owner: string, repo: string, issueNumber: number): GitHubIssue | null;
|
|
40
|
+
/**
|
|
41
|
+
* Open an issue in the default browser.
|
|
42
|
+
*/
|
|
43
|
+
export declare function openIssueInBrowser(url: string): void;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execaSync } from 'execa';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a GitHub URL to extract owner and repo.
|
|
4
|
+
* Handles formats like:
|
|
5
|
+
* - https://github.com/owner/repo
|
|
6
|
+
* - https://github.com/owner/repo.git
|
|
7
|
+
* - git@github.com:owner/repo.git
|
|
8
|
+
*/
|
|
9
|
+
export function parseGitHubUrl(url) {
|
|
10
|
+
// HTTPS format
|
|
11
|
+
const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
|
|
12
|
+
if (httpsMatch) {
|
|
13
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2].replace(/\.git$/, '') };
|
|
14
|
+
}
|
|
15
|
+
// SSH format
|
|
16
|
+
const sshMatch = url.match(/github\.com:([^/]+)\/([^/.\s]+)/);
|
|
17
|
+
if (sshMatch) {
|
|
18
|
+
return { owner: sshMatch[1], repo: sshMatch[2].replace(/\.git$/, '') };
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Check if gh CLI is installed and authenticated.
|
|
24
|
+
*/
|
|
25
|
+
export function isGhCliAvailable() {
|
|
26
|
+
try {
|
|
27
|
+
execaSync('gh', ['auth', 'status']);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Fetch issues for a GitHub repository using gh CLI.
|
|
36
|
+
*/
|
|
37
|
+
export function fetchGitHubIssues(owner, repo, options = {}) {
|
|
38
|
+
const { state = 'open', limit = 50 } = options;
|
|
39
|
+
try {
|
|
40
|
+
const result = execaSync('gh', [
|
|
41
|
+
'issue', 'list',
|
|
42
|
+
'--repo', `${owner}/${repo}`,
|
|
43
|
+
'--state', state,
|
|
44
|
+
'--limit', String(limit),
|
|
45
|
+
'--json', 'number,title,state,author,labels,createdAt,updatedAt,comments,body,url',
|
|
46
|
+
]);
|
|
47
|
+
const issues = JSON.parse(result.stdout);
|
|
48
|
+
return issues.map((issue) => ({
|
|
49
|
+
number: issue.number,
|
|
50
|
+
title: issue.title,
|
|
51
|
+
state: issue.state.toLowerCase(),
|
|
52
|
+
author: issue.author.login,
|
|
53
|
+
labels: issue.labels.map((l) => l.name),
|
|
54
|
+
createdAt: issue.createdAt,
|
|
55
|
+
updatedAt: issue.updatedAt,
|
|
56
|
+
commentsCount: issue.comments.length,
|
|
57
|
+
body: issue.body || null,
|
|
58
|
+
url: issue.url,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
console.error('[github] Failed to fetch issues:', err);
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Fetch a single issue with full details.
|
|
68
|
+
*/
|
|
69
|
+
export function fetchGitHubIssue(owner, repo, issueNumber) {
|
|
70
|
+
try {
|
|
71
|
+
const result = execaSync('gh', [
|
|
72
|
+
'issue', 'view', String(issueNumber),
|
|
73
|
+
'--repo', `${owner}/${repo}`,
|
|
74
|
+
'--json', 'number,title,state,author,labels,createdAt,updatedAt,comments,body,url',
|
|
75
|
+
]);
|
|
76
|
+
const issue = JSON.parse(result.stdout);
|
|
77
|
+
return {
|
|
78
|
+
number: issue.number,
|
|
79
|
+
title: issue.title,
|
|
80
|
+
state: issue.state.toLowerCase(),
|
|
81
|
+
author: issue.author.login,
|
|
82
|
+
labels: issue.labels.map((l) => l.name),
|
|
83
|
+
createdAt: issue.createdAt,
|
|
84
|
+
updatedAt: issue.updatedAt,
|
|
85
|
+
commentsCount: issue.comments.length,
|
|
86
|
+
body: issue.body || null,
|
|
87
|
+
url: issue.url,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error('[github] Failed to fetch issue:', err);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Open an issue in the default browser.
|
|
97
|
+
*/
|
|
98
|
+
export function openIssueInBrowser(url) {
|
|
99
|
+
try {
|
|
100
|
+
execaSync('open', [url]);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Fallback for Linux
|
|
104
|
+
try {
|
|
105
|
+
execaSync('xdg-open', [url]);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Ignore if can't open
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type HotkeyScope = 'global' | 'global-dashboard' | 'project-context' | 'barn-context' | 'wiki-view' | 'issues-view' | 'livestock-detail' | 'logs-view' | 'night-sky' | 'list' | 'content';
|
|
2
|
+
export type HotkeyCategory = 'navigation' | 'action' | 'system';
|
|
3
|
+
export interface Hotkey {
|
|
4
|
+
key: string;
|
|
5
|
+
description: string;
|
|
6
|
+
category: HotkeyCategory;
|
|
7
|
+
scopes: HotkeyScope[];
|
|
8
|
+
panel?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const HOTKEYS: Hotkey[];
|
|
11
|
+
/**
|
|
12
|
+
* Get hotkeys for a specific view and optional panel focus
|
|
13
|
+
*/
|
|
14
|
+
export declare function getHotkeysForContext(scope: HotkeyScope, focusedPanel?: string, includeGlobal?: boolean): Hotkey[];
|
|
15
|
+
/**
|
|
16
|
+
* Get hotkeys formatted for the help overlay (grouped by category)
|
|
17
|
+
*/
|
|
18
|
+
export declare function getHotkeysGrouped(scope: HotkeyScope, focusedPanel?: string): {
|
|
19
|
+
navigation: Hotkey[];
|
|
20
|
+
action: Hotkey[];
|
|
21
|
+
system: Hotkey[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Format hotkeys as a single-line hint string
|
|
25
|
+
* Example: "[n] new [e] edit [d] delete [q] back"
|
|
26
|
+
*/
|
|
27
|
+
export declare function formatHotkeyHints(scope: HotkeyScope, focusedPanel?: string, maxKeys?: number): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/lib/hotkeys.ts
|
|
2
|
+
export const HOTKEYS = [
|
|
3
|
+
// === GLOBAL (everywhere) ===
|
|
4
|
+
{ key: '?', description: 'Toggle help', category: 'system', scopes: ['global'] },
|
|
5
|
+
{ key: 'q', description: 'Back / Detach', category: 'system', scopes: ['global'] },
|
|
6
|
+
{ key: 'Esc', description: 'Back / Cancel', category: 'system', scopes: ['global'] },
|
|
7
|
+
// === LIST NAVIGATION (any focused list) ===
|
|
8
|
+
{ key: 'j/k', description: 'Navigate up/down', category: 'navigation', scopes: ['list'] },
|
|
9
|
+
{ key: 'g/G', description: 'Go to first/last', category: 'navigation', scopes: ['list'] },
|
|
10
|
+
{ key: 'Enter', description: 'Select item', category: 'navigation', scopes: ['list'] },
|
|
11
|
+
// === CONTENT NAVIGATION (markdown panels) ===
|
|
12
|
+
{ key: 'j/k', description: 'Scroll up/down', category: 'navigation', scopes: ['content'] },
|
|
13
|
+
{ key: 'g/G', description: 'Jump to top/bottom', category: 'navigation', scopes: ['content'] },
|
|
14
|
+
{ key: 'PgUp/PgDn', description: 'Scroll page', category: 'navigation', scopes: ['content'] },
|
|
15
|
+
// === GLOBAL DASHBOARD ===
|
|
16
|
+
{ key: 'Tab', description: 'Switch panel', category: 'navigation', scopes: ['global-dashboard', 'project-context', 'barn-context', 'wiki-view', 'issues-view'] },
|
|
17
|
+
{ key: 'Q', description: 'Quit Yeehaw', category: 'system', scopes: ['global-dashboard'] },
|
|
18
|
+
{ key: 'c', description: 'New Claude session', category: 'action', scopes: ['global-dashboard', 'project-context'] },
|
|
19
|
+
{ key: '1-9', description: 'Quick switch window', category: 'action', scopes: ['global-dashboard', 'project-context'] },
|
|
20
|
+
{ key: 's', description: 'Open shell session', category: 'action', scopes: ['global-dashboard', 'project-context', 'barn-context', 'livestock-detail'], panel: 'barns' },
|
|
21
|
+
// === CONTEXT-AWARE ACTIONS ===
|
|
22
|
+
{ key: 'n', description: 'New (in focused panel)', category: 'action', scopes: ['global-dashboard', 'project-context', 'barn-context', 'wiki-view'] },
|
|
23
|
+
{ key: 'e', description: 'Edit selected', category: 'action', scopes: ['project-context', 'barn-context', 'wiki-view'] },
|
|
24
|
+
{ key: 'd', description: 'Delete (with confirm)', category: 'action', scopes: ['project-context', 'barn-context', 'wiki-view'] },
|
|
25
|
+
{ key: 'D', description: 'Delete container (type name)', category: 'action', scopes: ['project-context', 'barn-context'] },
|
|
26
|
+
// === PROJECT CONTEXT ===
|
|
27
|
+
{ key: 'w', description: 'Open wiki', category: 'action', scopes: ['project-context'] },
|
|
28
|
+
{ key: 'i', description: 'Open issues', category: 'action', scopes: ['project-context'] },
|
|
29
|
+
// === ISSUES VIEW ===
|
|
30
|
+
{ key: 'r', description: 'Refresh issues', category: 'action', scopes: ['issues-view'] },
|
|
31
|
+
{ key: 'o', description: 'Open in browser', category: 'action', scopes: ['issues-view'] },
|
|
32
|
+
// === LIVESTOCK DETAIL ===
|
|
33
|
+
{ key: 'l', description: 'View logs', category: 'action', scopes: ['livestock-detail'] },
|
|
34
|
+
// === LOGS VIEW ===
|
|
35
|
+
{ key: 'r', description: 'Refresh logs', category: 'action', scopes: ['logs-view'] },
|
|
36
|
+
// === NIGHT SKY (screensaver) ===
|
|
37
|
+
{ key: 'v', description: 'Visualizer', category: 'navigation', scopes: ['global-dashboard'] },
|
|
38
|
+
{ key: 'c', description: 'Spawn cloud', category: 'action', scopes: ['night-sky'] },
|
|
39
|
+
{ key: 'r', description: 'Randomize', category: 'action', scopes: ['night-sky'] },
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Get hotkeys for a specific view and optional panel focus
|
|
43
|
+
*/
|
|
44
|
+
export function getHotkeysForContext(scope, focusedPanel, includeGlobal = true) {
|
|
45
|
+
return HOTKEYS.filter((h) => {
|
|
46
|
+
// Include if scope matches
|
|
47
|
+
const scopeMatch = h.scopes.includes(scope) ||
|
|
48
|
+
(includeGlobal && h.scopes.includes('global'));
|
|
49
|
+
// If hotkey has panel requirement, check it
|
|
50
|
+
if (h.panel && focusedPanel && h.panel !== focusedPanel) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return scopeMatch;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Get hotkeys formatted for the help overlay (grouped by category)
|
|
58
|
+
*/
|
|
59
|
+
export function getHotkeysGrouped(scope, focusedPanel) {
|
|
60
|
+
const hotkeys = getHotkeysForContext(scope, focusedPanel);
|
|
61
|
+
return {
|
|
62
|
+
navigation: hotkeys.filter((h) => h.category === 'navigation'),
|
|
63
|
+
action: hotkeys.filter((h) => h.category === 'action'),
|
|
64
|
+
system: hotkeys.filter((h) => h.category === 'system'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Format hotkeys as a single-line hint string
|
|
69
|
+
* Example: "[n] new [e] edit [d] delete [q] back"
|
|
70
|
+
*/
|
|
71
|
+
export function formatHotkeyHints(scope, focusedPanel, maxKeys = 6) {
|
|
72
|
+
const hotkeys = getHotkeysForContext(scope, focusedPanel, true);
|
|
73
|
+
// Prioritize: actions first, then navigation, then system (except always include q)
|
|
74
|
+
const prioritized = [
|
|
75
|
+
...hotkeys.filter((h) => h.category === 'action'),
|
|
76
|
+
...hotkeys.filter((h) => h.category === 'navigation'),
|
|
77
|
+
...hotkeys.filter((h) => h.category === 'system' && h.key !== 'q' && h.key !== '?'),
|
|
78
|
+
...hotkeys.filter((h) => h.key === 'q' || h.key === '?'),
|
|
79
|
+
];
|
|
80
|
+
// Dedupe by key
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const unique = prioritized.filter((h) => {
|
|
83
|
+
if (seen.has(h.key))
|
|
84
|
+
return false;
|
|
85
|
+
seen.add(h.key);
|
|
86
|
+
return true;
|
|
87
|
+
});
|
|
88
|
+
return unique
|
|
89
|
+
.slice(0, maxKeys)
|
|
90
|
+
.map((h) => `[${h.key}] ${h.description.toLowerCase()}`)
|
|
91
|
+
.join(' ');
|
|
92
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './paths.js';
|
|
2
|
+
export * from './config.js';
|
|
3
|
+
export * from './tmux.js';
|
|
4
|
+
export * from './git.js';
|
|
5
|
+
export * from './livestock.js';
|
|
6
|
+
export * from './hotkeys.js';
|
|
7
|
+
export * from './shell.js';
|
|
8
|
+
export * from './errors.js';
|
|
9
|
+
export * from './mcp-validation.js';
|
|
10
|
+
export * from './detection.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './paths.js';
|
|
2
|
+
export * from './config.js';
|
|
3
|
+
export * from './tmux.js';
|
|
4
|
+
export * from './git.js';
|
|
5
|
+
export * from './livestock.js';
|
|
6
|
+
export * from './hotkeys.js';
|
|
7
|
+
export * from './shell.js';
|
|
8
|
+
export * from './errors.js';
|
|
9
|
+
export * from './mcp-validation.js';
|
|
10
|
+
export * from './detection.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Livestock, Barn } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a relative path within a livestock to an absolute path.
|
|
4
|
+
* For local livestock, returns the local absolute path.
|
|
5
|
+
* For remote livestock, returns the remote absolute path (for use with SSH).
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveLivestockPath(livestock: Livestock, relativePath: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Build SSH command prefix for a barn
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildSshCommand(barn: Barn): string[];
|
|
12
|
+
/**
|
|
13
|
+
* Read a file from livestock (local or remote via SSH)
|
|
14
|
+
*/
|
|
15
|
+
export declare function readLivestockFile(livestock: Livestock, relativePath: string): Promise<{
|
|
16
|
+
content: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
}>;
|
|
19
|
+
/**
|
|
20
|
+
* Read log files from livestock with filtering options
|
|
21
|
+
*/
|
|
22
|
+
export declare function readLivestockLogs(livestock: Livestock, options?: {
|
|
23
|
+
lines?: number;
|
|
24
|
+
pattern?: string;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
content: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Parse .env file content into key-value pairs
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseEnvFile(content: string): Record<string, string>;
|
|
33
|
+
/**
|
|
34
|
+
* Read env file from livestock, optionally hiding values
|
|
35
|
+
*/
|
|
36
|
+
export declare function readLivestockEnv(livestock: Livestock, showValues?: boolean): Promise<{
|
|
37
|
+
content: string;
|
|
38
|
+
error?: string;
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Detected framework configuration suggestions
|
|
42
|
+
*/
|
|
43
|
+
export interface DetectedConfig {
|
|
44
|
+
framework?: 'laravel' | 'django' | 'rails' | 'node' | 'unknown';
|
|
45
|
+
log_path?: string;
|
|
46
|
+
env_path?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Detect framework and suggest log_path/env_path based on project structure
|
|
50
|
+
*/
|
|
51
|
+
export declare function detectLivestockConfig(path: string, barn?: Barn): Promise<DetectedConfig>;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { join, isAbsolute } from 'path';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import { loadBarn } from './config.js';
|
|
5
|
+
import { shellEscape } from './shell.js';
|
|
6
|
+
import { getErrorMessage } from './errors.js';
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a relative path within a livestock to an absolute path.
|
|
9
|
+
* For local livestock, returns the local absolute path.
|
|
10
|
+
* For remote livestock, returns the remote absolute path (for use with SSH).
|
|
11
|
+
*/
|
|
12
|
+
export function resolveLivestockPath(livestock, relativePath) {
|
|
13
|
+
if (isAbsolute(relativePath)) {
|
|
14
|
+
return relativePath;
|
|
15
|
+
}
|
|
16
|
+
return join(livestock.path, relativePath);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build SSH command prefix for a barn
|
|
20
|
+
*/
|
|
21
|
+
export function buildSshCommand(barn) {
|
|
22
|
+
const args = ['ssh'];
|
|
23
|
+
if (barn.port && barn.port !== 22) {
|
|
24
|
+
args.push('-p', String(barn.port));
|
|
25
|
+
}
|
|
26
|
+
if (barn.identity_file) {
|
|
27
|
+
args.push('-i', barn.identity_file);
|
|
28
|
+
}
|
|
29
|
+
args.push(`${barn.user}@${barn.host}`);
|
|
30
|
+
return args;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Read a file from livestock (local or remote via SSH)
|
|
34
|
+
*/
|
|
35
|
+
export async function readLivestockFile(livestock, relativePath) {
|
|
36
|
+
const fullPath = resolveLivestockPath(livestock, relativePath);
|
|
37
|
+
// Local livestock
|
|
38
|
+
if (!livestock.barn) {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(fullPath)) {
|
|
41
|
+
return { content: '', error: `File not found: ${fullPath}` };
|
|
42
|
+
}
|
|
43
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
44
|
+
return { content };
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
return { content: '', error: `Failed to read file: ${err}` };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Remote livestock - SSH to barn
|
|
51
|
+
const barn = loadBarn(livestock.barn);
|
|
52
|
+
if (!barn) {
|
|
53
|
+
return { content: '', error: `Barn not found: ${livestock.barn}` };
|
|
54
|
+
}
|
|
55
|
+
if (!barn.host || !barn.user) {
|
|
56
|
+
return { content: '', error: `Barn '${barn.name}' is not configured for SSH` };
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const sshArgs = buildSshCommand(barn);
|
|
60
|
+
// Use shellEscape for the path to prevent injection
|
|
61
|
+
const result = await execa(sshArgs[0], [...sshArgs.slice(1), `cat ${shellEscape(fullPath)}`]);
|
|
62
|
+
return { content: result.stdout };
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return { content: '', error: `SSH error: ${getErrorMessage(err)}` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Read log files from livestock with filtering options
|
|
70
|
+
*/
|
|
71
|
+
export async function readLivestockLogs(livestock, options = {}) {
|
|
72
|
+
if (!livestock.log_path) {
|
|
73
|
+
return { content: '', error: 'log_path not configured for this livestock' };
|
|
74
|
+
}
|
|
75
|
+
const { lines = 100, pattern } = options;
|
|
76
|
+
const logPath = resolveLivestockPath(livestock, livestock.log_path);
|
|
77
|
+
// Build the command with proper escaping
|
|
78
|
+
let cmd;
|
|
79
|
+
const escapedLogPath = shellEscape(logPath);
|
|
80
|
+
const escapedLines = String(lines); // lines is a number, safe
|
|
81
|
+
if (pattern) {
|
|
82
|
+
// Escape the grep pattern for shell
|
|
83
|
+
const escapedPattern = shellEscape(pattern);
|
|
84
|
+
// grep with tail
|
|
85
|
+
cmd = `find ${escapedLogPath} -name '*.log' -type f 2>/dev/null | xargs tail -n ${escapedLines} 2>/dev/null | grep -i ${escapedPattern} || true`;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// Just tail the logs
|
|
89
|
+
cmd = `find ${escapedLogPath} -name '*.log' -type f 2>/dev/null | xargs tail -n ${escapedLines} 2>/dev/null || true`;
|
|
90
|
+
}
|
|
91
|
+
// Local livestock
|
|
92
|
+
if (!livestock.barn) {
|
|
93
|
+
try {
|
|
94
|
+
const result = await execa('sh', ['-c', cmd]);
|
|
95
|
+
if (!result.stdout.trim()) {
|
|
96
|
+
return { content: '', error: `No log files found in ${logPath}` };
|
|
97
|
+
}
|
|
98
|
+
return { content: result.stdout };
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return { content: '', error: `Failed to read logs: ${getErrorMessage(err)}` };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Remote livestock - SSH
|
|
105
|
+
const barn = loadBarn(livestock.barn);
|
|
106
|
+
if (!barn) {
|
|
107
|
+
return { content: '', error: `Barn not found: ${livestock.barn}` };
|
|
108
|
+
}
|
|
109
|
+
if (!barn.host || !barn.user) {
|
|
110
|
+
return { content: '', error: `Barn '${barn.name}' is not configured for SSH` };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const sshArgs = buildSshCommand(barn);
|
|
114
|
+
const result = await execa(sshArgs[0], [...sshArgs.slice(1), cmd]);
|
|
115
|
+
if (!result.stdout.trim()) {
|
|
116
|
+
return { content: '', error: `No log files found in ${logPath}` };
|
|
117
|
+
}
|
|
118
|
+
return { content: result.stdout };
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return { content: '', error: `SSH error: ${getErrorMessage(err)}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Parse .env file content into key-value pairs
|
|
126
|
+
*/
|
|
127
|
+
export function parseEnvFile(content) {
|
|
128
|
+
const result = {};
|
|
129
|
+
const lines = content.split('\n');
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
const trimmed = line.trim();
|
|
132
|
+
// Skip comments and empty lines
|
|
133
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
134
|
+
continue;
|
|
135
|
+
const eqIndex = trimmed.indexOf('=');
|
|
136
|
+
if (eqIndex === -1)
|
|
137
|
+
continue;
|
|
138
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
139
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
140
|
+
// Remove surrounding quotes
|
|
141
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
142
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
143
|
+
value = value.slice(1, -1);
|
|
144
|
+
}
|
|
145
|
+
result[key] = value;
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Read env file from livestock, optionally hiding values
|
|
151
|
+
*/
|
|
152
|
+
export async function readLivestockEnv(livestock, showValues = false) {
|
|
153
|
+
if (!livestock.env_path) {
|
|
154
|
+
return { content: '', error: 'env_path not configured for this livestock' };
|
|
155
|
+
}
|
|
156
|
+
const result = await readLivestockFile(livestock, livestock.env_path);
|
|
157
|
+
if (result.error) {
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
if (showValues) {
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
// Parse and return keys only
|
|
164
|
+
const parsed = parseEnvFile(result.content);
|
|
165
|
+
const keysOnly = Object.keys(parsed)
|
|
166
|
+
.map(key => `${key}=<hidden>`)
|
|
167
|
+
.join('\n');
|
|
168
|
+
return { content: keysOnly };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Check if a file exists (local or remote via SSH)
|
|
172
|
+
*/
|
|
173
|
+
async function fileExists(path, fullPath, barn) {
|
|
174
|
+
if (!barn) {
|
|
175
|
+
// Local check
|
|
176
|
+
return existsSync(join(path, fullPath));
|
|
177
|
+
}
|
|
178
|
+
// Remote check via SSH
|
|
179
|
+
if (!barn.host || !barn.user) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const sshArgs = buildSshCommand(barn);
|
|
184
|
+
const testPath = join(path, fullPath);
|
|
185
|
+
// Use shellEscape for the path
|
|
186
|
+
await execa(sshArgs[0], [...sshArgs.slice(1), `test -e ${shellEscape(testPath)}`]);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Detect framework and suggest log_path/env_path based on project structure
|
|
195
|
+
*/
|
|
196
|
+
export async function detectLivestockConfig(path, barn) {
|
|
197
|
+
const result = {};
|
|
198
|
+
// Laravel: artisan file
|
|
199
|
+
if (await fileExists(path, 'artisan', barn)) {
|
|
200
|
+
result.framework = 'laravel';
|
|
201
|
+
result.log_path = 'storage/logs/';
|
|
202
|
+
result.env_path = '.env';
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
// Django: manage.py
|
|
206
|
+
if (await fileExists(path, 'manage.py', barn)) {
|
|
207
|
+
result.framework = 'django';
|
|
208
|
+
result.log_path = 'logs/';
|
|
209
|
+
result.env_path = '.env';
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
// Rails: Gemfile + bin/rails
|
|
213
|
+
if (await fileExists(path, 'Gemfile', barn) && await fileExists(path, 'bin/rails', barn)) {
|
|
214
|
+
result.framework = 'rails';
|
|
215
|
+
result.log_path = 'log/';
|
|
216
|
+
result.env_path = '.env';
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
// Node: package.json
|
|
220
|
+
if (await fileExists(path, 'package.json', barn)) {
|
|
221
|
+
result.framework = 'node';
|
|
222
|
+
result.log_path = 'logs/';
|
|
223
|
+
result.env_path = '.env';
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
// Generic fallback: check if .env exists
|
|
227
|
+
if (await fileExists(path, '.env', barn)) {
|
|
228
|
+
result.framework = 'unknown';
|
|
229
|
+
result.env_path = '.env';
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool arguments come as Record<string, unknown> | undefined.
|
|
3
|
+
* These helpers safely extract and validate values.
|
|
4
|
+
*/
|
|
5
|
+
export interface McpArgs {
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Get a required string argument, throwing if missing or wrong type
|
|
10
|
+
*/
|
|
11
|
+
export declare function requireString(args: McpArgs | undefined, key: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Get an optional string argument
|
|
14
|
+
*/
|
|
15
|
+
export declare function optionalString(args: McpArgs | undefined, key: string): string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Get an optional number argument
|
|
18
|
+
*/
|
|
19
|
+
export declare function optionalNumber(args: McpArgs | undefined, key: string): number | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Get an optional boolean argument with default
|
|
22
|
+
*/
|
|
23
|
+
export declare function optionalBoolean(args: McpArgs | undefined, key: string, defaultValue: boolean): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Wrapper to handle validation errors and return MCP error response
|
|
26
|
+
*/
|
|
27
|
+
export declare function validateArgs<T>(args: McpArgs | undefined, validator: (args: McpArgs | undefined) => T): {
|
|
28
|
+
data: T;
|
|
29
|
+
error?: never;
|
|
30
|
+
} | {
|
|
31
|
+
data?: never;
|
|
32
|
+
error: string;
|
|
33
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// src/lib/mcp-validation.ts
|
|
2
|
+
import { isString, isNumber, isBoolean } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Get a required string argument, throwing if missing or wrong type
|
|
5
|
+
*/
|
|
6
|
+
export function requireString(args, key) {
|
|
7
|
+
const value = args?.[key];
|
|
8
|
+
if (!isString(value)) {
|
|
9
|
+
throw new Error(`Missing or invalid required parameter: ${key}`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Get an optional string argument
|
|
15
|
+
*/
|
|
16
|
+
export function optionalString(args, key) {
|
|
17
|
+
const value = args?.[key];
|
|
18
|
+
if (value === undefined || value === null) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (!isString(value)) {
|
|
22
|
+
throw new Error(`Invalid parameter type for ${key}: expected string`);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Get an optional number argument
|
|
28
|
+
*/
|
|
29
|
+
export function optionalNumber(args, key) {
|
|
30
|
+
const value = args?.[key];
|
|
31
|
+
if (value === undefined || value === null) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (!isNumber(value)) {
|
|
35
|
+
throw new Error(`Invalid parameter type for ${key}: expected number`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get an optional boolean argument with default
|
|
41
|
+
*/
|
|
42
|
+
export function optionalBoolean(args, key, defaultValue) {
|
|
43
|
+
const value = args?.[key];
|
|
44
|
+
if (value === undefined || value === null) {
|
|
45
|
+
return defaultValue;
|
|
46
|
+
}
|
|
47
|
+
if (!isBoolean(value)) {
|
|
48
|
+
throw new Error(`Invalid parameter type for ${key}: expected boolean`);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Wrapper to handle validation errors and return MCP error response
|
|
54
|
+
*/
|
|
55
|
+
export function validateArgs(args, validator) {
|
|
56
|
+
try {
|
|
57
|
+
return { data: validator(args) };
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
61
|
+
}
|
|
62
|
+
}
|