@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,11 @@
|
|
|
1
|
+
import type { Config, Project, Barn } from '../types.js';
|
|
2
|
+
interface UseConfigReturn {
|
|
3
|
+
config: Config;
|
|
4
|
+
projects: Project[];
|
|
5
|
+
barns: Barn[];
|
|
6
|
+
currentProject: Project | null;
|
|
7
|
+
setCurrentProjectName: (name: string | null) => void;
|
|
8
|
+
reload: () => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function useConfig(): UseConfigReturn;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { watch } from 'chokidar';
|
|
3
|
+
import { loadConfig, loadProjects, loadBarns, loadProject } from '../lib/config.js';
|
|
4
|
+
import { YEEHAW_DIR } from '../lib/paths.js';
|
|
5
|
+
export function useConfig() {
|
|
6
|
+
const [config, setConfig] = useState(() => loadConfig());
|
|
7
|
+
const [projects, setProjects] = useState(() => loadProjects());
|
|
8
|
+
const [barns, setBarns] = useState(() => loadBarns());
|
|
9
|
+
const [currentProjectName, setCurrentProjectName] = useState(() => loadConfig().default_project);
|
|
10
|
+
const reload = () => {
|
|
11
|
+
setConfig(loadConfig());
|
|
12
|
+
setProjects(loadProjects());
|
|
13
|
+
setBarns(loadBarns());
|
|
14
|
+
};
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const watcher = watch(YEEHAW_DIR, {
|
|
17
|
+
ignoreInitial: true,
|
|
18
|
+
depth: 2,
|
|
19
|
+
});
|
|
20
|
+
watcher.on('all', () => {
|
|
21
|
+
reload();
|
|
22
|
+
});
|
|
23
|
+
return () => {
|
|
24
|
+
watcher.close();
|
|
25
|
+
};
|
|
26
|
+
}, []);
|
|
27
|
+
const currentProject = currentProjectName ? loadProject(currentProjectName) : null;
|
|
28
|
+
return {
|
|
29
|
+
config,
|
|
30
|
+
projects,
|
|
31
|
+
barns,
|
|
32
|
+
currentProject,
|
|
33
|
+
setCurrentProjectName,
|
|
34
|
+
reload,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Barn } from '../types.js';
|
|
2
|
+
import { type DetectionState } from '../lib/detection.js';
|
|
3
|
+
export interface RemoteEnvironment {
|
|
4
|
+
barn: Barn;
|
|
5
|
+
state: DetectionState;
|
|
6
|
+
}
|
|
7
|
+
interface UseRemoteYeehawReturn {
|
|
8
|
+
environments: RemoteEnvironment[];
|
|
9
|
+
isDetecting: boolean;
|
|
10
|
+
refresh: () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function useRemoteYeehaw(barns: Barn[]): UseRemoteYeehawReturn;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { probeBarns, isCacheFresh } from '../lib/detection.js';
|
|
3
|
+
import { hasValidSshConfig, isLocalBarn } from '../lib/config.js';
|
|
4
|
+
export function useRemoteYeehaw(barns) {
|
|
5
|
+
const [results, setResults] = useState(new Map());
|
|
6
|
+
const [isDetecting, setIsDetecting] = useState(false);
|
|
7
|
+
const sshBarns = barns.filter(b => !isLocalBarn(b) && hasValidSshConfig(b));
|
|
8
|
+
const runDetection = useCallback(async () => {
|
|
9
|
+
if (sshBarns.length === 0)
|
|
10
|
+
return;
|
|
11
|
+
setIsDetecting(true);
|
|
12
|
+
try {
|
|
13
|
+
const detectionResults = await probeBarns(sshBarns);
|
|
14
|
+
setResults(prev => {
|
|
15
|
+
const next = new Map(prev);
|
|
16
|
+
for (const result of detectionResults) {
|
|
17
|
+
next.set(result.barnName, result);
|
|
18
|
+
}
|
|
19
|
+
return next;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
setIsDetecting(false);
|
|
24
|
+
}
|
|
25
|
+
}, [sshBarns.map(b => b.name).join(',')]);
|
|
26
|
+
// Run detection on mount and when barns change
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// Check if we need to refresh any cached results
|
|
29
|
+
const needsRefresh = sshBarns.some(barn => {
|
|
30
|
+
const cached = results.get(barn.name);
|
|
31
|
+
return !cached || !isCacheFresh(cached);
|
|
32
|
+
});
|
|
33
|
+
if (needsRefresh) {
|
|
34
|
+
runDetection();
|
|
35
|
+
}
|
|
36
|
+
}, [sshBarns.map(b => b.name).join(',')]);
|
|
37
|
+
// Build environments list - only include available barns
|
|
38
|
+
const environments = sshBarns
|
|
39
|
+
.map(barn => ({
|
|
40
|
+
barn,
|
|
41
|
+
state: results.get(barn.name)?.state ?? 'not-checked',
|
|
42
|
+
}))
|
|
43
|
+
.filter(env => env.state === 'available');
|
|
44
|
+
return {
|
|
45
|
+
environments,
|
|
46
|
+
isDetecting,
|
|
47
|
+
refresh: runDetection,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type TmuxWindow } from '../lib/tmux.js';
|
|
2
|
+
interface UseSessionsReturn {
|
|
3
|
+
windows: TmuxWindow[];
|
|
4
|
+
loading: boolean;
|
|
5
|
+
refresh: () => void;
|
|
6
|
+
createClaude: (workingDir: string, name: string) => number;
|
|
7
|
+
createShell: (workingDir: string, name: string) => number;
|
|
8
|
+
attachToWindow: (index: number) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function useSessions(): UseSessionsReturn;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { listYeehawWindows, createClaudeWindow, createShellWindow, switchToWindow, } from '../lib/tmux.js';
|
|
3
|
+
export function useSessions() {
|
|
4
|
+
const [windows, setWindows] = useState([]);
|
|
5
|
+
const [loading, setLoading] = useState(true);
|
|
6
|
+
const refresh = useCallback(() => {
|
|
7
|
+
const result = listYeehawWindows();
|
|
8
|
+
setWindows(prev => {
|
|
9
|
+
// Only update if windows actually changed (avoids unnecessary re-renders)
|
|
10
|
+
if (prev.length !== result.length)
|
|
11
|
+
return result;
|
|
12
|
+
const changed = result.some((w, i) => w.index !== prev[i].index || w.name !== prev[i].name);
|
|
13
|
+
return changed ? result : prev;
|
|
14
|
+
});
|
|
15
|
+
}, []);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
// Initial load with loading state
|
|
18
|
+
const result = listYeehawWindows();
|
|
19
|
+
setWindows(result);
|
|
20
|
+
setLoading(false);
|
|
21
|
+
// Poll every 5 seconds for window updates (without loading state changes)
|
|
22
|
+
const interval = setInterval(refresh, 5000);
|
|
23
|
+
return () => clearInterval(interval);
|
|
24
|
+
}, [refresh]);
|
|
25
|
+
const createClaude = useCallback((workingDir, name) => {
|
|
26
|
+
const windowIndex = createClaudeWindow(workingDir, name);
|
|
27
|
+
refresh();
|
|
28
|
+
return windowIndex;
|
|
29
|
+
}, [refresh]);
|
|
30
|
+
const createShell = useCallback((workingDir, name) => {
|
|
31
|
+
const windowIndex = createShellWindow(workingDir, name);
|
|
32
|
+
refresh();
|
|
33
|
+
return windowIndex;
|
|
34
|
+
}, [refresh]);
|
|
35
|
+
const attachToWindow = useCallback((index) => {
|
|
36
|
+
switchToWindow(index);
|
|
37
|
+
}, []);
|
|
38
|
+
return {
|
|
39
|
+
windows,
|
|
40
|
+
loading,
|
|
41
|
+
refresh,
|
|
42
|
+
createClaude,
|
|
43
|
+
createShell,
|
|
44
|
+
attachToWindow,
|
|
45
|
+
};
|
|
46
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from 'ink';
|
|
4
|
+
import { execaSync } from 'execa';
|
|
5
|
+
import { App } from './app.js';
|
|
6
|
+
import { isInsideYeehawSession, yeehawSessionExists, createYeehawSession, attachToYeehaw, hasTmux, } from './lib/tmux.js';
|
|
7
|
+
import { ensureConfigDirs } from './lib/config.js';
|
|
8
|
+
function main() {
|
|
9
|
+
// Ensure config directories exist
|
|
10
|
+
ensureConfigDirs();
|
|
11
|
+
// Check if tmux is available
|
|
12
|
+
if (!hasTmux()) {
|
|
13
|
+
console.error('Error: tmux is required but not installed');
|
|
14
|
+
console.error('Install tmux and try again');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
// If we're already inside the yeehaw tmux session, just render the TUI
|
|
18
|
+
if (isInsideYeehawSession()) {
|
|
19
|
+
render(_jsx(App, {}));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// We're not inside yeehaw session - need to create/attach
|
|
23
|
+
if (!yeehawSessionExists()) {
|
|
24
|
+
// Create new session with yeehaw running in window 0
|
|
25
|
+
createYeehawSession();
|
|
26
|
+
// Now we need to run yeehaw inside window 0
|
|
27
|
+
// Send the command to the window
|
|
28
|
+
execaSync('tmux', ['send-keys', '-t', 'yeehaw:0', 'yeehaw', 'Enter']);
|
|
29
|
+
}
|
|
30
|
+
// Attach to the yeehaw session
|
|
31
|
+
// This will exec into tmux and not return
|
|
32
|
+
attachToYeehaw();
|
|
33
|
+
}
|
|
34
|
+
main();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Config, Project, Barn, Livestock } from '../types.js';
|
|
2
|
+
export declare const LOCAL_BARN: Barn;
|
|
3
|
+
export declare function isLocalBarn(barn: Barn): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Type guard to check if a barn has valid SSH configuration.
|
|
6
|
+
* Returns true only if all required SSH fields are present.
|
|
7
|
+
*/
|
|
8
|
+
export declare function hasValidSshConfig(barn: Barn): barn is Barn & {
|
|
9
|
+
host: string;
|
|
10
|
+
user: string;
|
|
11
|
+
port: number;
|
|
12
|
+
identity_file: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function ensureConfigDirs(): void;
|
|
15
|
+
export declare function loadConfig(): Config;
|
|
16
|
+
export declare function loadProjects(): Project[];
|
|
17
|
+
export declare function loadProject(name: string): Project | null;
|
|
18
|
+
export declare function loadBarns(): Barn[];
|
|
19
|
+
export declare function loadBarn(name: string): Barn | null;
|
|
20
|
+
export declare function saveProject(project: Project): void;
|
|
21
|
+
export declare function deleteProject(name: string): boolean;
|
|
22
|
+
export declare function saveBarn(barn: Barn): void;
|
|
23
|
+
export declare function deleteBarn(name: string): boolean;
|
|
24
|
+
export declare function getLivestockForBarn(barnName: string): Array<{
|
|
25
|
+
project: Project;
|
|
26
|
+
livestock: Livestock;
|
|
27
|
+
}>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
|
|
2
|
+
import YAML from 'js-yaml';
|
|
3
|
+
import { YEEHAW_DIR, CONFIG_FILE, PROJECTS_DIR, BARNS_DIR, SESSIONS_DIR, getProjectPath, getBarnPath, } from './paths.js';
|
|
4
|
+
// The local barn is always available - represents the local machine
|
|
5
|
+
export const LOCAL_BARN = {
|
|
6
|
+
name: 'local',
|
|
7
|
+
critters: [],
|
|
8
|
+
};
|
|
9
|
+
// Check if a barn is the local machine
|
|
10
|
+
export function isLocalBarn(barn) {
|
|
11
|
+
return barn.name === 'local';
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Type guard to check if a barn has valid SSH configuration.
|
|
15
|
+
* Returns true only if all required SSH fields are present.
|
|
16
|
+
*/
|
|
17
|
+
export function hasValidSshConfig(barn) {
|
|
18
|
+
return (!isLocalBarn(barn) &&
|
|
19
|
+
typeof barn.host === 'string' && barn.host.length > 0 &&
|
|
20
|
+
typeof barn.user === 'string' && barn.user.length > 0 &&
|
|
21
|
+
typeof barn.port === 'number' &&
|
|
22
|
+
typeof barn.identity_file === 'string' && barn.identity_file.length > 0);
|
|
23
|
+
}
|
|
24
|
+
const DEFAULT_CONFIG = {
|
|
25
|
+
version: 1,
|
|
26
|
+
default_project: null,
|
|
27
|
+
editor: 'vim',
|
|
28
|
+
theme: 'dark',
|
|
29
|
+
show_activity: true,
|
|
30
|
+
claude: {
|
|
31
|
+
model: 'claude-sonnet-4-20250514',
|
|
32
|
+
auto_attach: true,
|
|
33
|
+
},
|
|
34
|
+
tmux: {
|
|
35
|
+
session_prefix: 'yh-',
|
|
36
|
+
default_shell: '/bin/zsh',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
export function ensureConfigDirs() {
|
|
40
|
+
const dirs = [YEEHAW_DIR, PROJECTS_DIR, BARNS_DIR, SESSIONS_DIR];
|
|
41
|
+
for (const dir of dirs) {
|
|
42
|
+
if (!existsSync(dir)) {
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function loadConfig() {
|
|
48
|
+
ensureConfigDirs();
|
|
49
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
50
|
+
writeFileSync(CONFIG_FILE, YAML.dump(DEFAULT_CONFIG), 'utf-8');
|
|
51
|
+
return DEFAULT_CONFIG;
|
|
52
|
+
}
|
|
53
|
+
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
|
54
|
+
const parsed = YAML.load(content);
|
|
55
|
+
return { ...DEFAULT_CONFIG, ...parsed };
|
|
56
|
+
}
|
|
57
|
+
function normalizeProject(project) {
|
|
58
|
+
project.livestock = project.livestock || [];
|
|
59
|
+
return project;
|
|
60
|
+
}
|
|
61
|
+
export function loadProjects() {
|
|
62
|
+
ensureConfigDirs();
|
|
63
|
+
if (!existsSync(PROJECTS_DIR))
|
|
64
|
+
return [];
|
|
65
|
+
const files = readdirSync(PROJECTS_DIR).filter((f) => f.endsWith('.yaml'));
|
|
66
|
+
return files.map((file) => {
|
|
67
|
+
const content = readFileSync(getProjectPath(file.replace('.yaml', '')), 'utf-8');
|
|
68
|
+
const project = YAML.load(content);
|
|
69
|
+
return normalizeProject(project);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export function loadProject(name) {
|
|
73
|
+
const path = getProjectPath(name);
|
|
74
|
+
if (!existsSync(path))
|
|
75
|
+
return null;
|
|
76
|
+
const content = readFileSync(path, 'utf-8');
|
|
77
|
+
const project = YAML.load(content);
|
|
78
|
+
return normalizeProject(project);
|
|
79
|
+
}
|
|
80
|
+
export function loadBarns() {
|
|
81
|
+
ensureConfigDirs();
|
|
82
|
+
// Always include the local barn first
|
|
83
|
+
const barns = [LOCAL_BARN];
|
|
84
|
+
if (!existsSync(BARNS_DIR))
|
|
85
|
+
return barns;
|
|
86
|
+
const files = readdirSync(BARNS_DIR).filter((f) => f.endsWith('.yaml'));
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
const content = readFileSync(getBarnPath(file.replace('.yaml', '')), 'utf-8');
|
|
89
|
+
const barn = YAML.load(content);
|
|
90
|
+
// Skip if someone manually created a 'local' barn file
|
|
91
|
+
if (barn.name !== 'local') {
|
|
92
|
+
barns.push(barn);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return barns;
|
|
96
|
+
}
|
|
97
|
+
export function loadBarn(name) {
|
|
98
|
+
// Local barn is always available
|
|
99
|
+
if (name === 'local') {
|
|
100
|
+
return LOCAL_BARN;
|
|
101
|
+
}
|
|
102
|
+
const path = getBarnPath(name);
|
|
103
|
+
if (!existsSync(path))
|
|
104
|
+
return null;
|
|
105
|
+
const content = readFileSync(path, 'utf-8');
|
|
106
|
+
return YAML.load(content);
|
|
107
|
+
}
|
|
108
|
+
export function saveProject(project) {
|
|
109
|
+
ensureConfigDirs();
|
|
110
|
+
const path = getProjectPath(project.name);
|
|
111
|
+
const content = YAML.dump(project);
|
|
112
|
+
writeFileSync(path, content, 'utf-8');
|
|
113
|
+
}
|
|
114
|
+
export function deleteProject(name) {
|
|
115
|
+
const path = getProjectPath(name);
|
|
116
|
+
if (!existsSync(path))
|
|
117
|
+
return false;
|
|
118
|
+
unlinkSync(path);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
export function saveBarn(barn) {
|
|
122
|
+
ensureConfigDirs();
|
|
123
|
+
const path = getBarnPath(barn.name);
|
|
124
|
+
const content = YAML.dump(barn);
|
|
125
|
+
writeFileSync(path, content, 'utf-8');
|
|
126
|
+
}
|
|
127
|
+
export function deleteBarn(name) {
|
|
128
|
+
// Cannot delete the local barn
|
|
129
|
+
if (name === 'local')
|
|
130
|
+
return false;
|
|
131
|
+
const path = getBarnPath(name);
|
|
132
|
+
if (!existsSync(path))
|
|
133
|
+
return false;
|
|
134
|
+
unlinkSync(path);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
// Get all livestock deployed to a specific barn (derived from projects)
|
|
138
|
+
export function getLivestockForBarn(barnName) {
|
|
139
|
+
const projects = loadProjects();
|
|
140
|
+
const result = [];
|
|
141
|
+
for (const project of projects) {
|
|
142
|
+
for (const livestock of project.livestock || []) {
|
|
143
|
+
// Match by barn name, or match local barn with undefined/missing barn field
|
|
144
|
+
if (livestock.barn === barnName || (barnName === 'local' && !livestock.barn)) {
|
|
145
|
+
result.push({ project, livestock });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Barn } from '../types.js';
|
|
2
|
+
export type DetectionState = 'not-checked' | 'checking' | 'available' | 'unavailable' | 'unreachable';
|
|
3
|
+
export interface BarnDetectionResult {
|
|
4
|
+
barnName: string;
|
|
5
|
+
state: DetectionState;
|
|
6
|
+
checkedAt: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Probe a single barn to check if Yeehaw is running.
|
|
10
|
+
* Returns 'available' if tmux session 'yeehaw' exists on the remote.
|
|
11
|
+
*/
|
|
12
|
+
export declare function probeBarns(barns: Barn[]): Promise<BarnDetectionResult[]>;
|
|
13
|
+
/**
|
|
14
|
+
* Check if a cached result is still fresh.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isCacheFresh(result: BarnDetectionResult): boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { hasValidSshConfig, isLocalBarn } from './config.js';
|
|
3
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
4
|
+
const SSH_TIMEOUT_SECONDS = 5;
|
|
5
|
+
/**
|
|
6
|
+
* Probe a single barn to check if Yeehaw is running.
|
|
7
|
+
* Returns 'available' if tmux session 'yeehaw' exists on the remote.
|
|
8
|
+
*/
|
|
9
|
+
export async function probeBarns(barns) {
|
|
10
|
+
const sshBarns = barns.filter(b => !isLocalBarn(b) && hasValidSshConfig(b));
|
|
11
|
+
const probes = sshBarns.map(async (barn) => {
|
|
12
|
+
if (!hasValidSshConfig(barn)) {
|
|
13
|
+
return { barnName: barn.name, state: 'unreachable', checkedAt: Date.now() };
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const result = await execa('ssh', [
|
|
17
|
+
'-o', 'ConnectTimeout=' + SSH_TIMEOUT_SECONDS,
|
|
18
|
+
'-o', 'BatchMode=yes',
|
|
19
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
20
|
+
'-p', String(barn.port),
|
|
21
|
+
'-i', barn.identity_file,
|
|
22
|
+
`${barn.user}@${barn.host}`,
|
|
23
|
+
'tmux has-session -t yeehaw 2>/dev/null && echo "yeehaw:running"'
|
|
24
|
+
], { timeout: (SSH_TIMEOUT_SECONDS + 2) * 1000 });
|
|
25
|
+
const state = result.stdout.includes('yeehaw:running')
|
|
26
|
+
? 'available'
|
|
27
|
+
: 'unavailable';
|
|
28
|
+
return { barnName: barn.name, state, checkedAt: Date.now() };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { barnName: barn.name, state: 'unreachable', checkedAt: Date.now() };
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return Promise.all(probes);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Check if a cached result is still fresh.
|
|
38
|
+
*/
|
|
39
|
+
export function isCacheFresh(result) {
|
|
40
|
+
return Date.now() - result.checkedAt < CACHE_TTL_MS;
|
|
41
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execaSync } from 'execa';
|
|
2
|
+
import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { randomBytes } from 'crypto';
|
|
6
|
+
/**
|
|
7
|
+
* Open content in user's editor and return the edited content.
|
|
8
|
+
* Similar to how git opens commit messages.
|
|
9
|
+
*/
|
|
10
|
+
export function editInEditor(initialContent, filename = 'edit.md') {
|
|
11
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
|
|
12
|
+
const tempFile = join(tmpdir(), `yeehaw-${randomBytes(4).toString('hex')}-${filename}`);
|
|
13
|
+
try {
|
|
14
|
+
// Write initial content to temp file
|
|
15
|
+
writeFileSync(tempFile, initialContent, 'utf-8');
|
|
16
|
+
// Open editor (this blocks until editor is closed)
|
|
17
|
+
execaSync(editor, [tempFile], {
|
|
18
|
+
stdio: 'inherit',
|
|
19
|
+
});
|
|
20
|
+
// Read back the edited content
|
|
21
|
+
if (existsSync(tempFile)) {
|
|
22
|
+
const content = readFileSync(tempFile, 'utf-8');
|
|
23
|
+
unlinkSync(tempFile);
|
|
24
|
+
return content;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
// Clean up temp file if it exists
|
|
30
|
+
if (existsSync(tempFile)) {
|
|
31
|
+
unlinkSync(tempFile);
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safely extract an error message from an unknown error value.
|
|
3
|
+
* This is the recommended pattern for catch blocks in TypeScript.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* try {
|
|
7
|
+
* await riskyOperation();
|
|
8
|
+
* } catch (err) {
|
|
9
|
+
* return { error: getErrorMessage(err) };
|
|
10
|
+
* }
|
|
11
|
+
*/
|
|
12
|
+
export declare function getErrorMessage(err: unknown): string;
|
|
13
|
+
/**
|
|
14
|
+
* Type guard to check if a value is a non-null object
|
|
15
|
+
*/
|
|
16
|
+
export declare function isObject(value: unknown): value is Record<string, unknown>;
|
|
17
|
+
/**
|
|
18
|
+
* Type guard to check if a value is a string
|
|
19
|
+
*/
|
|
20
|
+
export declare function isString(value: unknown): value is string;
|
|
21
|
+
/**
|
|
22
|
+
* Type guard to check if a value is a number
|
|
23
|
+
*/
|
|
24
|
+
export declare function isNumber(value: unknown): value is number;
|
|
25
|
+
/**
|
|
26
|
+
* Type guard to check if a value is a boolean
|
|
27
|
+
*/
|
|
28
|
+
export declare function isBoolean(value: unknown): value is boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/lib/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* Safely extract an error message from an unknown error value.
|
|
4
|
+
* This is the recommended pattern for catch blocks in TypeScript.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* try {
|
|
8
|
+
* await riskyOperation();
|
|
9
|
+
* } catch (err) {
|
|
10
|
+
* return { error: getErrorMessage(err) };
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
export function getErrorMessage(err) {
|
|
14
|
+
if (err instanceof Error) {
|
|
15
|
+
return err.message;
|
|
16
|
+
}
|
|
17
|
+
if (typeof err === 'string') {
|
|
18
|
+
return err;
|
|
19
|
+
}
|
|
20
|
+
if (err && typeof err === 'object' && 'message' in err) {
|
|
21
|
+
return String(err.message);
|
|
22
|
+
}
|
|
23
|
+
return String(err);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Type guard to check if a value is a non-null object
|
|
27
|
+
*/
|
|
28
|
+
export function isObject(value) {
|
|
29
|
+
return typeof value === 'object' && value !== null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Type guard to check if a value is a string
|
|
33
|
+
*/
|
|
34
|
+
export function isString(value) {
|
|
35
|
+
return typeof value === 'string';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Type guard to check if a value is a number
|
|
39
|
+
*/
|
|
40
|
+
export function isNumber(value) {
|
|
41
|
+
return typeof value === 'number' && !isNaN(value);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Type guard to check if a value is a boolean
|
|
45
|
+
*/
|
|
46
|
+
export function isBoolean(value) {
|
|
47
|
+
return typeof value === 'boolean';
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Barn } from '../types.js';
|
|
2
|
+
export interface GitInfo {
|
|
3
|
+
isGitRepo: boolean;
|
|
4
|
+
remoteUrl?: string;
|
|
5
|
+
branch?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectGitInfo(path: string): GitInfo;
|
|
8
|
+
/**
|
|
9
|
+
* Detect git info on a remote server via SSH.
|
|
10
|
+
*/
|
|
11
|
+
export declare function detectRemoteGitInfo(path: string, barn: Barn): GitInfo;
|
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execaSync } from 'execa';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { hasValidSshConfig } from './config.js';
|
|
6
|
+
import { shellEscape } from './shell.js';
|
|
7
|
+
function expandPath(path) {
|
|
8
|
+
if (path.startsWith('~/')) {
|
|
9
|
+
return join(homedir(), path.slice(2));
|
|
10
|
+
}
|
|
11
|
+
return path;
|
|
12
|
+
}
|
|
13
|
+
export function detectGitInfo(path) {
|
|
14
|
+
const expandedPath = expandPath(path);
|
|
15
|
+
// Check if .git exists
|
|
16
|
+
if (!existsSync(join(expandedPath, '.git'))) {
|
|
17
|
+
return { isGitRepo: false };
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
// Get remote URL
|
|
21
|
+
const remoteResult = execaSync('git', ['-C', expandedPath, 'config', '--get', 'remote.origin.url'], {
|
|
22
|
+
reject: false,
|
|
23
|
+
});
|
|
24
|
+
const remoteUrl = remoteResult.exitCode === 0 ? remoteResult.stdout.trim() : undefined;
|
|
25
|
+
// Get current branch
|
|
26
|
+
const branchResult = execaSync('git', ['-C', expandedPath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
27
|
+
reject: false,
|
|
28
|
+
});
|
|
29
|
+
const branch = branchResult.exitCode === 0 ? branchResult.stdout.trim() : undefined;
|
|
30
|
+
return {
|
|
31
|
+
isGitRepo: true,
|
|
32
|
+
remoteUrl,
|
|
33
|
+
branch,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { isGitRepo: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Detect git info on a remote server via SSH.
|
|
42
|
+
*/
|
|
43
|
+
export function detectRemoteGitInfo(path, barn) {
|
|
44
|
+
// Verify barn has valid SSH config
|
|
45
|
+
if (!hasValidSshConfig(barn)) {
|
|
46
|
+
return { isGitRepo: false };
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
// Run git commands via SSH
|
|
50
|
+
const result = execaSync('ssh', [
|
|
51
|
+
'-p', String(barn.port),
|
|
52
|
+
'-i', barn.identity_file,
|
|
53
|
+
'-o', 'BatchMode=yes',
|
|
54
|
+
'-o', 'ConnectTimeout=5',
|
|
55
|
+
`${barn.user}@${barn.host}`,
|
|
56
|
+
`cd ${shellEscape(path)} && git config --get remote.origin.url 2>/dev/null && git rev-parse --abbrev-ref HEAD 2>/dev/null`
|
|
57
|
+
], { timeout: 10000, reject: false });
|
|
58
|
+
if (result.exitCode !== 0) {
|
|
59
|
+
return { isGitRepo: false };
|
|
60
|
+
}
|
|
61
|
+
const lines = result.stdout.trim().split('\n');
|
|
62
|
+
const remoteUrl = lines[0] || undefined;
|
|
63
|
+
const branch = lines[1] || undefined;
|
|
64
|
+
return {
|
|
65
|
+
isGitRepo: true,
|
|
66
|
+
remoteUrl,
|
|
67
|
+
branch,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { isGitRepo: false };
|
|
72
|
+
}
|
|
73
|
+
}
|