@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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/dist/app.d.ts +1 -0
  4. package/dist/app.js +414 -0
  5. package/dist/components/BarnHeader.d.ts +6 -0
  6. package/dist/components/BarnHeader.js +21 -0
  7. package/dist/components/BottomBar.d.ts +16 -0
  8. package/dist/components/BottomBar.js +7 -0
  9. package/dist/components/Header.d.ts +8 -0
  10. package/dist/components/Header.js +83 -0
  11. package/dist/components/HelpOverlay.d.ts +7 -0
  12. package/dist/components/HelpOverlay.js +17 -0
  13. package/dist/components/List.d.ts +17 -0
  14. package/dist/components/List.js +53 -0
  15. package/dist/components/Markdown.d.ts +8 -0
  16. package/dist/components/Markdown.js +23 -0
  17. package/dist/components/Panel.d.ts +10 -0
  18. package/dist/components/Panel.js +5 -0
  19. package/dist/components/PathInput.d.ts +9 -0
  20. package/dist/components/PathInput.js +141 -0
  21. package/dist/components/ScrollableMarkdown.d.ts +11 -0
  22. package/dist/components/ScrollableMarkdown.js +56 -0
  23. package/dist/components/StatusBar.d.ts +5 -0
  24. package/dist/components/StatusBar.js +20 -0
  25. package/dist/components/TextArea.d.ts +17 -0
  26. package/dist/components/TextArea.js +140 -0
  27. package/dist/components/index.d.ts +5 -0
  28. package/dist/components/index.js +5 -0
  29. package/dist/hooks/index.d.ts +3 -0
  30. package/dist/hooks/index.js +3 -0
  31. package/dist/hooks/useConfig.d.ts +11 -0
  32. package/dist/hooks/useConfig.js +36 -0
  33. package/dist/hooks/useRemoteYeehaw.d.ts +13 -0
  34. package/dist/hooks/useRemoteYeehaw.js +49 -0
  35. package/dist/hooks/useSessions.d.ts +11 -0
  36. package/dist/hooks/useSessions.js +46 -0
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.js +34 -0
  39. package/dist/lib/config.d.ts +27 -0
  40. package/dist/lib/config.js +150 -0
  41. package/dist/lib/detection.d.ts +16 -0
  42. package/dist/lib/detection.js +41 -0
  43. package/dist/lib/editor.d.ts +5 -0
  44. package/dist/lib/editor.js +35 -0
  45. package/dist/lib/errors.d.ts +28 -0
  46. package/dist/lib/errors.js +48 -0
  47. package/dist/lib/git.d.ts +11 -0
  48. package/dist/lib/git.js +73 -0
  49. package/dist/lib/github.d.ts +43 -0
  50. package/dist/lib/github.js +111 -0
  51. package/dist/lib/hotkeys.d.ts +27 -0
  52. package/dist/lib/hotkeys.js +92 -0
  53. package/dist/lib/index.d.ts +10 -0
  54. package/dist/lib/index.js +10 -0
  55. package/dist/lib/livestock.d.ts +51 -0
  56. package/dist/lib/livestock.js +233 -0
  57. package/dist/lib/mcp-validation.d.ts +33 -0
  58. package/dist/lib/mcp-validation.js +62 -0
  59. package/dist/lib/paths.d.ts +8 -0
  60. package/dist/lib/paths.js +28 -0
  61. package/dist/lib/shell.d.ts +34 -0
  62. package/dist/lib/shell.js +61 -0
  63. package/dist/lib/ssh.d.ts +15 -0
  64. package/dist/lib/ssh.js +77 -0
  65. package/dist/lib/tmux-config.d.ts +3 -0
  66. package/dist/lib/tmux-config.js +42 -0
  67. package/dist/lib/tmux.d.ts +32 -0
  68. package/dist/lib/tmux.js +397 -0
  69. package/dist/mcp-server.d.ts +23 -0
  70. package/dist/mcp-server.js +825 -0
  71. package/dist/types.d.ts +89 -0
  72. package/dist/types.js +2 -0
  73. package/dist/views/BarnContext.d.ts +22 -0
  74. package/dist/views/BarnContext.js +252 -0
  75. package/dist/views/GlobalDashboard.d.ts +16 -0
  76. package/dist/views/GlobalDashboard.js +253 -0
  77. package/dist/views/Home.d.ts +11 -0
  78. package/dist/views/Home.js +27 -0
  79. package/dist/views/IssuesView.d.ts +7 -0
  80. package/dist/views/IssuesView.js +157 -0
  81. package/dist/views/LivestockDetailView.d.ts +11 -0
  82. package/dist/views/LivestockDetailView.js +140 -0
  83. package/dist/views/LogsView.d.ts +8 -0
  84. package/dist/views/LogsView.js +84 -0
  85. package/dist/views/NightSkyView.d.ts +5 -0
  86. package/dist/views/NightSkyView.js +441 -0
  87. package/dist/views/ProjectContext.d.ts +18 -0
  88. package/dist/views/ProjectContext.js +333 -0
  89. package/dist/views/Projects.d.ts +8 -0
  90. package/dist/views/Projects.js +20 -0
  91. package/dist/views/WikiView.d.ts +8 -0
  92. package/dist/views/WikiView.js +138 -0
  93. package/dist/views/index.d.ts +2 -0
  94. package/dist/views/index.js +2 -0
  95. package/package.json +65 -0
@@ -0,0 +1,3 @@
1
+ export { useConfig } from './useConfig.js';
2
+ export { useSessions } from './useSessions.js';
3
+ export { useRemoteYeehaw } from './useRemoteYeehaw.js';
@@ -0,0 +1,3 @@
1
+ export { useConfig } from './useConfig.js';
2
+ export { useSessions } from './useSessions.js';
3
+ export { useRemoteYeehaw } from './useRemoteYeehaw.js';
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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,5 @@
1
+ /**
2
+ * Open content in user's editor and return the edited content.
3
+ * Similar to how git opens commit messages.
4
+ */
5
+ export declare function editInEditor(initialContent: string, filename?: string): string | null;
@@ -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;
@@ -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
+ }