diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts new file mode 100644 index 00000000..1272beea --- /dev/null +++ b/src/remote-hosts.ts @@ -0,0 +1,80 @@ +import { existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; +import type { RemoteCase, RemoteCommandMode, RemoteHost, SessionMode, SessionRemote } from './types.js'; + +const REMOTE_HOSTS_FILE = 'remote-hosts.json'; +const REMOTE_CASES_FILE = 'remote-cases.json'; + +export function remoteHostsPath(configDir: string): string { + return join(configDir, REMOTE_HOSTS_FILE); +} + +export function remoteCasesPath(configDir: string): string { + return join(configDir, REMOTE_CASES_FILE); +} + +async function readJsonArray(path: string): Promise { + try { + const raw = await fs.readFile(path, 'utf-8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +async function writeJsonArray(configDir: string, path: string, value: T[]): Promise { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await fs.writeFile(path, JSON.stringify(value, null, 2)); +} + +export async function readRemoteHosts(configDir: string): Promise { + return readJsonArray(remoteHostsPath(configDir)); +} + +export async function writeRemoteHosts(configDir: string, hosts: RemoteHost[]): Promise { + await writeJsonArray(configDir, remoteHostsPath(configDir), hosts); +} + +export async function readRemoteCases(configDir: string): Promise { + return readJsonArray(remoteCasesPath(configDir)); +} + +export async function writeRemoteCases(configDir: string, cases: RemoteCase[]): Promise { + await writeJsonArray(configDir, remoteCasesPath(configDir), cases); +} + +export function defaultRemoteCommandForMode(mode: SessionMode): string { + const commands: Record = { + shell: 'exec bash -l', + claude: 'exec claude', + opencode: 'exec opencode', + codex: 'exec codex', + gemini: 'exec gemini', + }; + return commands[mode as RemoteCommandMode] || commands.shell; +} + +export function remoteSshTarget(host: Pick): string { + return `${host.username}@${host.host}`; +} + +export function remoteDisplayPath( + remote: Pick | { username: string; host: string; path: string } +): string { + const path = 'remotePath' in remote ? remote.remotePath : remote.path; + return `${remote.username}@${remote.host}:${path}`; +} + +export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote { + return { + hostId: host.id, + label: host.label, + host: host.host, + username: host.username, + port: host.port, + remotePath: remoteCase.remotePath, + commands: host.commands, + }; +} diff --git a/src/types/session.ts b/src/types/session.ts index 460f6e67..c10f7b27 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -43,6 +43,34 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedToo /** Session mode: which CLI backend a session runs */ export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'; +export type RemoteCommandMode = Extract; + +export interface RemoteHost { + id: string; + label: string; + host: string; + username: string; + port?: number; + commands?: Partial>; +} + +export interface RemoteCase { + name: string; + type: 'remote'; + hostId: string; + remotePath: string; +} + +export interface SessionRemote { + hostId: string; + label: string; + host: string; + username: string; + port?: number; + remotePath: string; + commands?: Partial>; +} + /** * Valid Claude CLI effort levels (claude >= 2.1.154). * `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a @@ -160,6 +188,8 @@ export interface SessionState { status: SessionStatus; /** Working directory path */ workingDir: string; + /** Remote execution metadata, present when this session runs over SSH through local tmux */ + remote?: SessionRemote; /** ID of currently assigned task, null if none */ currentTaskId: string | null; /** Timestamp when session was created */ diff --git a/test/remote-hosts.test.ts b/test/remote-hosts.test.ts new file mode 100644 index 00000000..05ce917d --- /dev/null +++ b/test/remote-hosts.test.ts @@ -0,0 +1,66 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + defaultRemoteCommandForMode, + readRemoteCases, + readRemoteHosts, + remoteDisplayPath, + remoteSshTarget, + writeRemoteCases, + writeRemoteHosts, +} from '../src/remote-hosts.js'; + +describe('remote-hosts domain', () => { + let dir: string | null = null; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = null; + }); + + function configDir(): string { + dir = mkdtempSync(join(tmpdir(), 'codeman-remote-hosts-')); + return dir; + } + + it('round-trips remote hosts and remote cases from a config directory', async () => { + const root = configDir(); + await writeRemoteHosts(root, [ + { + id: 'gpu-box', + label: 'GPU Box', + host: '10.0.0.42', + username: 'ubuntu', + commands: { codex: 'exec codx personal' }, + }, + ]); + await writeRemoteCases(root, [ + { name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }, + ]); + + await expect(readRemoteHosts(root)).resolves.toEqual([ + { + id: 'gpu-box', + label: 'GPU Box', + host: '10.0.0.42', + username: 'ubuntu', + commands: { codex: 'exec codx personal' }, + }, + ]); + await expect(readRemoteCases(root)).resolves.toEqual([ + { name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }, + ]); + }); + + it('returns safe mode defaults and remote display values', () => { + expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l'); + expect(defaultRemoteCommandForMode('codex')).toBe('exec codex'); + expect(defaultRemoteCommandForMode('claude')).toBe('exec claude'); + expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local'); + expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe( + 'aamer@box.local:/opt/work' + ); + }); +});