Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions src/cockpit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function parseCockpitArgs(rawArgs = []) {
const options = {
sessionName: DEFAULT_SESSION_NAME,
attach: false,
noTmux: false,
target: process.cwd(),
};

Expand All @@ -24,6 +25,10 @@ function parseCockpitArgs(rawArgs = []) {
options.attach = true;
continue;
}
if (arg === '--no-tmux') {
options.noTmux = true;
continue;
}
if (arg === '--session') {
const next = rawArgs[index + 1];
if (!next || next.startsWith('-')) {
Expand Down Expand Up @@ -55,6 +60,19 @@ function parseCockpitArgs(rawArgs = []) {
return options;
}

function shellQuote(value) {
return `'${String(value).replace(/'/g, "'\\''")}'`;
}

function cockpitRendererCommand(repoRoot) {
try {
const entry = require.resolve('./index');
return `${shellQuote(process.execPath)} ${shellQuote(entry)} ${shellQuote(repoRoot)}`;
} catch (_error) {
return `gx cockpit --no-tmux --target ${shellQuote(repoRoot)}`;
}
}

function render(repoPath = process.cwd()) {
return renderCockpit(readCockpitState(repoPath));
}
Expand Down Expand Up @@ -93,9 +111,18 @@ function openCockpit(rawArgs, deps = {}) {

const options = parseCockpitArgs(rawArgs);
const repoRoot = resolveRepoRoot(options.target);
const controlCommand = 'gx agents status';
if (options.noTmux) {
stdout.write(render(repoRoot));
return { action: 'rendered', sessionName: options.sessionName, repoRoot };
}

tmux.ensureTmuxAvailable();
const controlCommand = cockpitRendererCommand(repoRoot);

try {
tmux.ensureTmuxAvailable();
} catch (error) {
throw new Error(`${error.message}\nPreview without tmux: gx cockpit --no-tmux`);
}

if (tmux.sessionExists(options.sessionName)) {
stdout.write(`[${toolName}] Attaching tmux session '${options.sessionName}'.\n`);
Expand All @@ -109,14 +136,18 @@ function openCockpit(rawArgs, deps = {}) {
const detail = String(createResult.stderr || createResult.stdout || '').trim();
throw new Error(`tmux could not create session '${options.sessionName}'${detail ? `: ${detail}` : '.'}`);
}
const sendResult = tmux.sendKeys(options.sessionName, controlCommand);
const paneId = String(createResult.stdout || '').trim();
if (!paneId) {
throw new Error(`tmux did not return a control pane id for session '${options.sessionName}'.`);
}
const sendResult = tmux.sendKeys(paneId, controlCommand);
if (sendResult.error) throw sendResult.error;
if (sendResult.status !== 0) {
const detail = String(sendResult.stderr || sendResult.stdout || '').trim();
throw new Error(`tmux could not start cockpit control pane${detail ? `: ${detail}` : '.'}`);
}
stdout.write(`[${toolName}] Created tmux session '${options.sessionName}' in ${repoRoot}.\n`);
stdout.write(`[${toolName}] Control pane: gx agents status\n`);
stdout.write(`[${toolName}] Control pane: ${controlCommand}\n`);

if (options.attach) {
tmux.attachSession(options.sessionName);
Expand All @@ -136,6 +167,7 @@ if (require.main === module) {
module.exports = {
DEFAULT_SESSION_NAME,
parseCockpitArgs,
cockpitRendererCommand,
openCockpit,
render,
startCockpit,
Expand Down
2 changes: 1 addition & 1 deletion src/tmux/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function sessionExists(name) {
}

function createSession(name, cwd) {
const args = ['new-session', '-d', '-s', requireName(name)];
const args = ['new-session', '-d', '-s', requireName(name), '-P', '-F', '#{pane_id}'];
addCwd(args, cwd);
return tmux.runTmux(args);
}
Expand Down
56 changes: 47 additions & 9 deletions test/cockpit-command.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ test('cockpit creates the default tmux session in the repo root', () => {
'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' +
'if [[ "$1" == "-V" ]]; then echo "tmux 3.4"; exit 0; fi\n' +
'if [[ "$1" == "has-session" ]]; then exit 1; fi\n' +
'if [[ "$1" == "new-session" ]]; then exit 0; fi\n' +
'if [[ "$1" == "new-session" ]]; then echo "%7"; exit 0; fi\n' +
'if [[ "$1" == "send-keys" ]]; then exit 0; fi\n' +
'exit 9\n',
);
Expand All @@ -32,14 +32,28 @@ test('cockpit creates the default tmux session in the repo root', () => {

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /Created tmux session 'guardex'/);
assert.match(result.stdout, /Control pane: gx agents status/);
assert.match(result.stdout, /Control pane: .*src\/cockpit\/index\.js/);
const lines = fs.readFileSync(log, 'utf8').trim().split('\n');
assert.match(lines[1], /^.* :: has-session -t guardex$/);
assert.match(lines[2], new RegExp(`^${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} :: new-session -d -s guardex -c ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`));
assert.match(lines[3], /^.* :: send-keys -t guardex gx agents status C-m$/);
assert.match(lines[2], new RegExp(`^${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} :: new-session -d -s guardex -P -F #\\{pane_id\\} -c ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`));
assert.match(lines[3], /^.* :: send-keys -t %7 .*src\/cockpit\/index\.js.* C-m$/);
});

test('cockpit attaches when the tmux session already exists', () => {
test('cockpit --no-tmux renders once without tmux', () => {
const repoDir = initRepo();
const missingTmux = path.join(os.tmpdir(), `missing-tmux-${process.pid}-${Date.now()}`);

const result = runNodeWithEnv(['cockpit', '--no-tmux', '--target', repoDir], repoDir, {
GUARDEX_TMUX_BIN: missingTmux,
});

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /GitGuardex Cockpit/);
assert.match(result.stdout, new RegExp(`repo: ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
assert.match(result.stdout, /No active agent sessions\./);
});

test('cockpit attaches to an existing tmux session', () => {
const repoDir = initRepo();
const { bin, log } = fakeTmux(
'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' +
Expand All @@ -61,13 +75,13 @@ test('cockpit attaches when the tmux session already exists', () => {
assert.doesNotMatch(logged, /new-session/);
});

test('cockpit --attach creates then attaches when the session is missing', () => {
test('cockpit creates a new tmux session with an explicit control pane target', () => {
const repoDir = initRepo();
const { bin, log } = fakeTmux(
'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' +
'if [[ "$1" == "-V" ]]; then exit 0; fi\n' +
'if [[ "$1" == "has-session" ]]; then exit 1; fi\n' +
'if [[ "$1" == "new-session" ]]; then exit 0; fi\n' +
'if [[ "$1" == "new-session" ]]; then echo "%9"; exit 0; fi\n' +
'if [[ "$1" == "send-keys" ]]; then exit 0; fi\n' +
'if [[ "$1" == "attach-session" ]]; then exit 0; fi\n' +
'exit 9\n',
Expand All @@ -79,11 +93,34 @@ test('cockpit --attach creates then attaches when the session is missing', () =>

assert.equal(result.status, 0, result.stderr || result.stdout);
const logged = fs.readFileSync(log, 'utf8');
assert.match(logged, /new-session -d -s guardex-dev/);
assert.match(logged, /send-keys -t guardex-dev gx agents status C-m/);
assert.match(logged, /new-session -d -s guardex-dev -P -F #\{pane_id\}/);
assert.match(logged, /send-keys -t %9 .*src\/cockpit\/index\.js.* C-m/);
assert.match(logged, /attach-session -t guardex-dev/);
});

test('cockpit creates a custom tmux session name', () => {
const repoDir = initRepo();
const { bin, log } = fakeTmux(
'printf "%s\\n" "$PWD :: $*" >> "$LOG"\n' +
'if [[ "$1" == "-V" ]]; then exit 0; fi\n' +
'if [[ "$1" == "has-session" ]]; then exit 1; fi\n' +
'if [[ "$1" == "new-session" ]]; then echo "%11"; exit 0; fi\n' +
'if [[ "$1" == "send-keys" ]]; then exit 0; fi\n' +
'exit 9\n',
);

const result = runNodeWithEnv(['cockpit', '--session', 'ops-cockpit', '--target', repoDir], repoDir, {
GUARDEX_TMUX_BIN: bin,
});

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /Created tmux session 'ops-cockpit'/);
const logged = fs.readFileSync(log, 'utf8');
assert.match(logged, /has-session -t ops-cockpit/);
assert.match(logged, /new-session -d -s ops-cockpit -P -F #\{pane_id\}/);
assert.match(logged, /send-keys -t %11 /);
});

test('cockpit reports a helpful error when tmux is unavailable', () => {
const repoDir = initRepo();
const missingTmux = path.join(os.tmpdir(), `missing-tmux-${process.pid}-${Date.now()}`);
Expand All @@ -92,4 +129,5 @@ test('cockpit reports a helpful error when tmux is unavailable', () => {

assert.equal(result.status, 1);
assert.match(result.stderr, /tmux is required for gx cockpit\. Install tmux and retry\./);
assert.match(result.stderr, /Preview without tmux: gx cockpit --no-tmux/);
});
2 changes: 1 addition & 1 deletion test/tmux-session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ test('createSession builds detached session argv with cwd', () => {
tmuxSession.createSession('gx-cockpit', '/repo');
assert.deepEqual(calls, [
{
args: ['new-session', '-d', '-s', 'gx-cockpit', '-c', '/repo'],
args: ['new-session', '-d', '-s', 'gx-cockpit', '-P', '-F', '#{pane_id}', '-c', '/repo'],
options: undefined,
},
]);
Expand Down
Loading