Skip to content

Commit ca37668

Browse files
authored
Merge pull request #26 from web3dev1337/fix/issue-25-gt-path
fix: resolve Homebrew gt path and prevent activity-feed ENOENT crash
2 parents 21af0a8 + 8985d7e commit ca37668

10 files changed

Lines changed: 412 additions & 20 deletions

File tree

CODEBASE_DOCUMENTATION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ server/infrastructure/CommandRunner.js - Safe child_process.execFile wrapper
6565
6666
server/infrastructure/CacheRegistry.js - TTL cache for CLI output
6767
server/infrastructure/EventBus.js - Internal pub/sub for cache invalidation
68+
server/infrastructure/ExecutableResolver.js - Resolves gt/bd binaries from PATH, env overrides, and Homebrew/Linuxbrew fallback paths
6869
```
6970

7071
## Backend — Services
@@ -161,6 +162,7 @@ test/e2e.test.js - Puppeteer browser tests (real server + browser)
161162
test/integration.test.js - Legacy integration tests
162163
test/integration/endpoints.test.js - API endpoint contract tests
163164
test/integration/cli-compatibility.test.js - Real server + stubbed CLI coverage for gt/bd old/new command fallback behavior
165+
test/integration/cli-executable-resolution.test.js - Real server coverage for gt/bd executable path resolution and missing-gt activity-stream crash prevention
164166
test/integration/rig-parsing-fallback.test.js - Real server + stubbed CLI coverage for rig list fallback/emoji parsing
165167
test/integration/websocket.test.js - WebSocket lifecycle tests
166168
test/integration/cache.test.js - Cache invalidation tests
@@ -173,6 +175,7 @@ test/unit/ - 32 unit test files covering:
173175
├─ Infrastructure: cacheRegistry, commandRunner, eventBus
174176
├─ Services: statusService, targetService, githubService, convoyService,
175177
│ formulaService, beadService, workService
178+
├─ CLI resolution: executableResolver
176179
├─ Routes: statusRoutes, targetRoutes, githubRoutes, convoyRoutes,
177180
│ formulaRoutes, beadRoutes, workRoutes
178181
├─ Frontend: state, htmlUtils, quoteArg, formattingTime, animationsShared,

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ gastown-gui help
177177
| `GASTOWN_PORT` | Server port | 7667 |
178178
| `HOST` | Server host | 127.0.0.1 |
179179
| `GT_ROOT` | Gas Town root directory | ~/gt |
180+
| `GT_BIN` | Override `gt` executable path | auto-detect (`PATH`, `/opt/homebrew/bin/gt`, `/usr/local/bin/gt`) |
181+
| `BD_BIN` | Override `bd` executable path | auto-detect (`PATH`, `/opt/homebrew/bin/bd`, `/usr/local/bin/bd`) |
180182

181183
---
182184

server.js

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import {
3232
import { AgentPath } from './server/domain/values/AgentPath.js';
3333
import { CommandRunner } from './server/infrastructure/CommandRunner.js';
3434
import { CacheRegistry } from './server/infrastructure/CacheRegistry.js';
35+
import {
36+
DEFAULT_BD_FALLBACK_PATHS,
37+
DEFAULT_GT_FALLBACK_PATHS,
38+
resolveExecutable,
39+
} from './server/infrastructure/ExecutableResolver.js';
3540
import { BDGateway } from './server/gateways/BDGateway.js';
3641
import { GTGateway } from './server/gateways/GTGateway.js';
3742
import { GitHubGateway } from './server/gateways/GitHubGateway.js';
@@ -60,10 +65,20 @@ const PORT = process.env.GASTOWN_PORT || 7667;
6065
const HOST = process.env.HOST || '127.0.0.1';
6166
const HOME = process.env.HOME || os.homedir();
6267
const GT_ROOT = process.env.GT_ROOT || path.join(HOME, 'gt');
68+
const GT_EXECUTABLE = resolveExecutable({
69+
command: 'gt',
70+
envVarName: 'GT_BIN',
71+
fallbackPaths: DEFAULT_GT_FALLBACK_PATHS,
72+
});
73+
const BD_EXECUTABLE = resolveExecutable({
74+
command: 'bd',
75+
envVarName: 'BD_BIN',
76+
fallbackPaths: DEFAULT_BD_FALLBACK_PATHS,
77+
});
6378

6479
const commandRunner = new CommandRunner();
65-
const gtGateway = new GTGateway({ runner: commandRunner, gtRoot: GT_ROOT });
66-
const bdGateway = new BDGateway({ runner: commandRunner, gtRoot: GT_ROOT });
80+
const gtGateway = new GTGateway({ runner: commandRunner, gtRoot: GT_ROOT, executable: GT_EXECUTABLE });
81+
const bdGateway = new BDGateway({ runner: commandRunner, gtRoot: GT_ROOT, executable: BD_EXECUTABLE });
6782
const tmuxGateway = new TmuxGateway({ runner: commandRunner });
6883
const backendCache = new CacheRegistry();
6984
const convoyService = new ConvoyService({
@@ -467,11 +482,11 @@ async function getPolecatOutput(sessionName, lines = 50) {
467482

468483
// Execute a Gas Town command
469484
async function executeGT(args, options = {}) {
470-
const cmd = `gt ${args.join(' ')}`;
485+
const cmd = `${GT_EXECUTABLE} ${args.join(' ')}`;
471486
console.log(`[GT] Executing: ${cmd}`);
472487

473488
try {
474-
const { stdout, stderr } = await execFileAsync('gt', args, {
489+
const { stdout, stderr } = await execFileAsync(GT_EXECUTABLE, args, {
475490
cwd: options.cwd || GT_ROOT,
476491
timeout: options.timeout || 30000,
477492
env: { ...process.env, ...options.env }
@@ -506,14 +521,14 @@ async function executeGT(args, options = {}) {
506521

507522
// Execute a Beads command
508523
async function executeBD(args, options = {}) {
509-
const cmd = `bd ${args.join(' ')}`;
524+
const cmd = `${BD_EXECUTABLE} ${args.join(' ')}`;
510525
console.log(`[BD] Executing: ${cmd}`);
511526

512527
// Set BEADS_DIR to ensure bd finds the database
513528
const beadsDir = path.join(GT_ROOT, '.beads');
514529

515530
try {
516-
const { stdout } = await execFileAsync('bd', args, {
531+
const { stdout } = await execFileAsync(BD_EXECUTABLE, args, {
517532
cwd: options.cwd || GT_ROOT,
518533
timeout: options.timeout || 30000,
519534
env: { ...process.env, BEADS_DIR: beadsDir }
@@ -1174,7 +1189,7 @@ app.get('/api/setup/status', async (req, res) => {
11741189

11751190
// Check gt
11761191
try {
1177-
const gtResult = await execFileAsync('gt', ['version'], { timeout: 5000 });
1192+
const gtResult = await execFileAsync(GT_EXECUTABLE, ['version'], { timeout: 5000 });
11781193
status.gt_installed = true;
11791194
status.gt_version = String(gtResult.stdout || '').trim().split('\n')[0];
11801195
} catch {
@@ -1183,7 +1198,7 @@ app.get('/api/setup/status', async (req, res) => {
11831198

11841199
// Check bd
11851200
try {
1186-
const bdResult = await execFileAsync('bd', ['version'], { timeout: 5000 });
1201+
const bdResult = await execFileAsync(BD_EXECUTABLE, ['version'], { timeout: 5000 });
11871202
status.bd_installed = true;
11881203
status.bd_version = String(bdResult.stdout || '').trim().split('\n')[0];
11891204
} catch {
@@ -1713,14 +1728,26 @@ registerGitHubRoutes(app, { gitHubService });
17131728

17141729
// Start activity stream
17151730
let activityProcess = null;
1731+
let activityRestartTimer = null;
1732+
1733+
function scheduleActivityRestart() {
1734+
if (clients.size === 0) return;
1735+
if (activityRestartTimer) return;
1736+
activityRestartTimer = setTimeout(() => {
1737+
activityRestartTimer = null;
1738+
if (clients.size > 0) {
1739+
startActivityStream();
1740+
}
1741+
}, 5000);
1742+
}
17161743

17171744
function startActivityStream() {
17181745
if (activityProcess) return;
17191746

17201747
console.log('[WS] Starting activity stream...');
17211748

17221749
// Use gt feed for comprehensive activity (beads + gt events + convoys)
1723-
activityProcess = spawn('gt', ['feed', '--plain', '--follow'], {
1750+
activityProcess = spawn(GT_EXECUTABLE, ['feed', '--plain', '--follow'], {
17241751
cwd: GT_ROOT
17251752
});
17261753

@@ -1738,13 +1765,16 @@ function startActivityStream() {
17381765
console.error(`[BD Activity] stderr: ${data}`);
17391766
});
17401767

1768+
activityProcess.on('error', (error) => {
1769+
console.error(`[BD Activity] Process error: ${error.message}`);
1770+
activityProcess = null;
1771+
scheduleActivityRestart();
1772+
});
1773+
17411774
activityProcess.on('close', (code) => {
17421775
console.log(`[BD Activity] Process exited with code ${code}`);
17431776
activityProcess = null;
1744-
// Restart after delay if clients connected
1745-
if (clients.size > 0) {
1746-
setTimeout(startActivityStream, 5000);
1747-
}
1777+
scheduleActivityRestart();
17481778
});
17491779
}
17501780

@@ -1816,9 +1846,15 @@ wss.on('connection', (ws) => {
18161846
clients.delete(ws);
18171847

18181848
// Stop activity stream if no clients
1819-
if (clients.size === 0 && activityProcess) {
1820-
activityProcess.kill();
1821-
activityProcess = null;
1849+
if (clients.size === 0) {
1850+
if (activityRestartTimer) {
1851+
clearTimeout(activityRestartTimer);
1852+
activityRestartTimer = null;
1853+
}
1854+
if (activityProcess) {
1855+
activityProcess.kill();
1856+
activityProcess = null;
1857+
}
18221858
}
18231859
});
18241860

server/gateways/BDGateway.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,19 @@ function parseJsonOrNull(text) {
99
}
1010

1111
export class BDGateway {
12-
constructor({ runner, gtRoot }) {
12+
constructor({ runner, gtRoot, executable = 'bd' }) {
1313
if (!runner?.exec) throw new Error('BDGateway requires a runner with exec()');
1414
if (!gtRoot) throw new Error('BDGateway requires gtRoot');
1515
this._runner = runner;
1616
this._gtRoot = gtRoot;
17+
this._executable = executable;
1718
this._beadsDir = path.join(gtRoot, '.beads');
1819
this._supportsNoDaemon = null;
1920
}
2021

2122
async exec(args, options = {}) {
2223
const env = { BEADS_DIR: this._beadsDir, ...(options.env ?? {}) };
23-
return this._runner.exec('bd', args, { cwd: this._gtRoot, ...options, env });
24+
return this._runner.exec(this._executable, args, { cwd: this._gtRoot, ...options, env });
2425
}
2526

2627
_resultText(result) {

server/gateways/GTGateway.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ function parseJsonOrNull(text) {
77
}
88

99
export class GTGateway {
10-
constructor({ runner, gtRoot }) {
10+
constructor({ runner, gtRoot, executable = 'gt' }) {
1111
if (!runner?.exec) throw new Error('GTGateway requires a runner with exec()');
1212
if (!gtRoot) throw new Error('GTGateway requires gtRoot');
1313
this._runner = runner;
1414
this._gtRoot = gtRoot;
15+
this._executable = executable;
1516
}
1617

1718
async exec(args, options = {}) {
18-
return this._runner.exec('gt', args, { cwd: this._gtRoot, ...options });
19+
return this._runner.exec(this._executable, args, { cwd: this._gtRoot, ...options });
1920
}
2021

2122
async status({ fast = true, allowExitCodes } = {}) {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
export const DEFAULT_GT_FALLBACK_PATHS = [
5+
'/opt/homebrew/bin/gt',
6+
'/usr/local/bin/gt',
7+
'/home/linuxbrew/.linuxbrew/bin/gt',
8+
];
9+
10+
export const DEFAULT_BD_FALLBACK_PATHS = [
11+
'/opt/homebrew/bin/bd',
12+
'/usr/local/bin/bd',
13+
'/home/linuxbrew/.linuxbrew/bin/bd',
14+
];
15+
16+
function hasPathSeparator(value) {
17+
return value.includes('/') || value.includes('\\');
18+
}
19+
20+
function canExecute(filePath) {
21+
try {
22+
fs.accessSync(filePath, fs.constants.X_OK);
23+
return true;
24+
} catch {
25+
return false;
26+
}
27+
}
28+
29+
function executableFromPath(command, env) {
30+
const pathValue = env.PATH || '';
31+
const dirs = pathValue.split(path.delimiter).filter(Boolean);
32+
for (const dir of dirs) {
33+
const candidate = path.join(dir, command);
34+
if (canExecute(candidate)) return candidate;
35+
}
36+
return null;
37+
}
38+
39+
export function resolveExecutable({
40+
command,
41+
envVarName,
42+
fallbackPaths = [],
43+
env = process.env,
44+
} = {}) {
45+
if (!command) throw new Error('resolveExecutable requires command');
46+
47+
const override = envVarName ? String(env[envVarName] || '').trim() : '';
48+
if (override) {
49+
if (canExecute(override)) return override;
50+
throw new Error(`${envVarName} is set but is not executable: ${override}`);
51+
}
52+
53+
if (hasPathSeparator(command) && canExecute(command)) {
54+
return command;
55+
}
56+
57+
const fromPath = executableFromPath(command, env);
58+
if (fromPath) return fromPath;
59+
60+
for (const candidate of fallbackPaths) {
61+
if (canExecute(candidate)) return candidate;
62+
}
63+
64+
return command;
65+
}

0 commit comments

Comments
 (0)